commit f3e8eca87b3b3eaec8f23ed1f5f8f40a4f74bf6b Author: Nexoniarz Date: Mon Oct 19 21:18:35 2026 +0200 Fry3DX 0.4: an easy 3D modeller Shapes with live settings, shape editing, knife and hole tools, drawing shapes onto other objects, combining objects (add / subtract / overlap), copies, thickness, painting with stickers and layers, a Layout tab, a Print tab with checks, packed .fry files with autosave, export to GLB, glTF, FBX, OBJ, 3MF, STL and PLY, folders in Layers, its own crash screen, and a website for GitHub Pages in docs/. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NMSrd5oe1GpUD7E6d825KP diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4234a9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build/ +.cache/ +*.blend1 +__pycache__/ +*.fry~ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..06fc132 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,43 @@ +cmake_minimum_required(VERSION 3.25) +if(UNIX AND NOT APPLE) + add_link_options(-rdynamic) # function names in crash reports +endif() +project(Fry3DX VERSION 0.1.0 LANGUAGES C CXX) + +# Fry3DX — Frying 3D Extreme: a 3D modelling app that's easy to pick up. Built on its own +# engine's window, renderer and UI (engine/). + +find_program(FRY_CCACHE ccache) +if(FRY_CCACHE AND NOT CMAKE_CXX_COMPILER_LAUNCHER) + set(CMAKE_C_COMPILER_LAUNCHER ${CMAKE_COMMAND} -E env CCACHE_SLOPPINESS=pch_defines,time_macros,include_file_mtime,include_file_ctime ${FRY_CCACHE}) + set(CMAKE_CXX_COMPILER_LAUNCHER ${CMAKE_C_COMPILER_LAUNCHER}) +endif() +find_program(FRY_MOLD mold) +if(FRY_MOLD AND CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_link_options(-fuse-ld=mold) +endif() + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) + +include(cmake/Dependencies.cmake) +include(cmake/Shaders.cmake) + +add_subdirectory(engine) +add_subdirectory(src) + +# Content next to the program: bin/keel/ (the engine's: fonts, sky, config); bin/Fry3DX/ (the app's own, none yet). +add_custom_target(content ALL + COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/content ${CMAKE_RUNTIME_OUTPUT_DIRECTORY} + COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/keel/fonts ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/Fry3DX + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${dejavu_SOURCE_DIR}/ttf/DejaVuSans.ttf ${dejavu_SOURCE_DIR}/ttf/DejaVuSans-Bold.ttf + ${dejavu_SOURCE_DIR}/LICENSE ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/keel/fonts/ + COMMENT "Copying content" VERBATIM) + +enable_testing() +add_subdirectory(tests EXCLUDE_FROM_ALL) +add_custom_target(check COMMAND ${CMAKE_CTEST_COMMAND} --test-dir ${CMAKE_BINARY_DIR} --output-on-failure + DEPENDS mesh_test USES_TERMINAL VERBATIM) diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..a1dbb08 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,12 @@ +{ + "version": 6, + "configurePresets": [ + { "name": "base", "hidden": true, "generator": "Ninja", "binaryDir": "${sourceDir}/build/${presetName}" }, + { "name": "debug", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } }, + { "name": "release", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo" } } + ], + "buildPresets": [ + { "name": "debug", "configurePreset": "debug" }, + { "name": "release", "configurePreset": "release" } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5907ef --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# Fry3DX — Frying 3D Extreme + +**A 3D modeller that does what you'd expect: pick a tool, click, drag.** +Made by [Nexoniarz](https://github.com/Nexoniarz) · Website: https://nexoniarz.github.io/Fry3DX/ + +![Fry3DX](docs/img/model.webp) + +## Why + +Because the well-known modellers suck at being easy: a wall of panels, shortcuts nobody would +guess, modes inside modes. Fry3DX is built the way people think — a toolbox like GIMP's, plain +words on every button, tooltips everywhere — and keeps the useful parts (rounding, mirroring, +combining, holes for screws, checks for 3D printing) one click away. + +## Build and run + +Linux, a Vulkan graphics card, CMake, Ninja, a C++20 compiler, glslc (shaderc) and SDL3. +Everything else is downloaded while building. On NixOS `shell.nix` has it all: + + git clone https://github.com/Nexoniarz/Fry3DX.git && cd Fry3DX + nix-shell --run 'cmake --preset release && cmake --build build/release' + build/release/bin/fry3dx [file.fry] + nix-shell --run 'cmake --build build/debug --target check' # tests + +## What's in it (v0.4) + +Five tabs at the top (Ctrl+1 to 5): **Model**, **Paint**, **Layout**, **Render**, **Print**. + +- **Knife** (K, shape editing): click points along the cut (or drag a straight line; Ctrl for + 45° steps), Enter or double-click cuts. Only what you see, or **right through**; then just the + edges, pieces of one object, each piece its own object, or the smaller piece deleted — the + inside **filled** (solid) or **left open** (hollow). +- **Hole** (O): press on a face, drag out the size (or type it in mm): round (any number of + sides, screw sizes M2-M8 ready), rectangle (width and height), triangle, or hexagon (nut sizes + M2-M8 ready, with a little room added for prints), turned as you like. All the way through, a + pocket of a set depth, or **only the outline**: cut into the face as a new face, picked, ready + to Extrude, Bevel or paint. Round edges then rounds its rim too. +- **Paint tab**: paint straight on the model (brush, eraser, fill, pick colour) or on its flat + picture beside it; the surface is laid out flat by itself. **Paste a picture** (Ctrl+V, copied + in GIMP or anywhere) or add one from a file, then drag its four corners to stretch and bend it + into place — you see it on the model as you go — and Enter places it. Pictures save beside the + .fry and go with the glTF export. +- **Pictures as layers**: a pasted picture goes onto the model as a **sticker** — drag it round + the model and over a corner or edge it bends onto the next face like a real one; its corners + size it (Shift stretches), the round handle turns it. Or put it on the flat picture and drag its + corners. **Enter locks** it as a layer (listed in Properties: hide, reorder, delete, paint in for + good); **clicking it in the Picture panel unlocks it** to move it again. +- **Files**: `.fry` is Fry3DX's own file — one file with the pictures and layers inside, packed + small (pictures as colour planes of predicted differences, then zstd). Old text `.fry` files + still open. **Autosave** (File > Autosave, every 2 minutes by default) keeps a highly packed + copy in the background; after a crash it offers it back. Closing, New or Open with changes asks: + **Save / Don't save / Cancel**. +- **Print tab** (Ctrl+5): your printer's plate drawn in the view (Prusa, Bambu, Creality, + Elegoo, Anycubic, or your own size), the size in millimetres (type one in: it keeps its shape), + fits or not, checks (holes, faces inside out, edges with more than two faces, faces with no + size) with buttons that fix them, overhangs shown red, weight and filament from the material + and infill, Measure (M), Lay flat on a face (L), Put on the bed (B), save 3MF / STL. +- **Object menu**: **add objects together, take one out of another, or keep only where they + overlap** (the last picked is kept; Ctrl+Shift+ / Ctrl+Shift-), always closed and printable; + **make copies** in a row or round in a circle (as objects or joined into one); **make it + thick** (a surface gets a back, a closed shape goes hollow); join flat faces side by side. +- **Layers panel**: **folders** (+ Folder, or Ctrl+G groups the picked ones; folders inside + folders; drag things onto them; a folder's eye and lock work on all in it; click it to pick all + of it), a **go-to button** at the end of every row bringing the camera to it, right-click for + rename, duplicate, show only this, move to a folder, delete. +- **Export** (Ctrl+E): GLB, glTF, **FBX** (Blender, Unity, Unreal, Maya; pictures inside), OBJ (+MTL), 3MF, STL (millimetres, for printing), PLY; painted + pictures and their layers go with it. + the sun (round, up, strength, colour), sky or a plain background, ground, + brightness, soft shadows, shade in corners, glow, colour; Save a picture (F12) saves just the + view, without panels or gizmos. + + +- **Toolbox like GIMP's**: picture buttons (hover for name and key), and *Tool options* under it. +- **Solid view** for modelling (plain grey room, studio light) and **Rendered** (real sky, + sun and shadows) — Z switches; Shift+Z wireframe; Alt+Z X-ray. +- **Shapes with settings you can change after adding** (+ in the toolbox, or Add): cube, rounded + cube, plane, circle, cylinder, tube, cone, pyramid, sphere, capsule, torus, wedge, stairs, arch; + **starter models**: table, chair, shelf, barrel, bottle, pillar, door frame, crate. +- **Draw shape** (D): click an outline on the ground, a side view's plane, or **straight on any + face of another object** → a new solid standing on it, added to it (raised), or cut into it. +- Move / Rotate / Scale (W/E/R): gizmos turn with the object (or the world's way, or — editing a + shape — out of the faces). Scale has a handle on each side; by default the other side stays put + (or the middle, the bottom, the origin); Shift scales both sides at once. Ctrl snaps. +- **Round, smooth, mirror** on any object, changeable any time: round every sharp edge, smooth it + out like clay (keeping sharp edges if you want), mirror across X/Y/Z. Bake them when you like. +- **Shape editing** (Tab or double-click): points / edges / faces (1/2/3); extrude (X), inset (I), + round edges (B), loop cut (C), cut in four, smooth, merge (M), make face, flip, separate (P). +- **Selecting**: click, box, brush or lasso; Shift+click (or Alt+click, double-click) a whole loop, + Shift+Ctrl+click an edge for the ring across it, Ctrl+click adds or takes one; more/less, + joined (L), invert, all/none. +- Join objects (Ctrl+J), reference pictures to trace over, layers (show, lock, rename). +- Undo / redo, `.fry` files, **export to glTF** (Ctrl+E), save a picture of the view (F12). +- F1 lists every key. If it ever crashes, its own crash screen offers to open it again, with the + last autosave offered back. + +## Coming + +Sculpting, materials (metal/rough shown in the view), scripting; animation later. + +## Layout + +- `engine/` — Fry3DX's engine: window, Vulkan/OpenGL renderer, UI, files. +- `src/mesh.*`, `src/tools.cpp` — polygon meshes and the edits on them (no window needed; tested). +- `src/shapes.*` — the shape library and starter models. +- `src/icons.*` — the toolbox pictures. +- `branding/` — the logo (SVG, and PNGs made from it with Inkscape). +- `src/scene.*` — objects, folders, `.fry` text; `src/package.cpp` the packed `.fry`. +- `src/export.cpp` — GLB/glTF/FBX/OBJ/3MF/STL/PLY; `src/print.cpp` — print checks. +- `src/boolean.cpp`, `src/modify.cpp` — combining shapes, thickness, joining flat faces. +- `docs/` — the website (GitHub Pages). Its text is all in `docs/content.js`. +- `src/app.cpp` — the app. diff --git a/branding/icon.png b/branding/icon.png new file mode 100644 index 0000000..bb30490 Binary files /dev/null and b/branding/icon.png differ diff --git a/branding/logo.png b/branding/logo.png new file mode 100644 index 0000000..76db8f9 Binary files /dev/null and b/branding/logo.png differ diff --git a/branding/logo.svg b/branding/logo.svg new file mode 100644 index 0000000..2ca9d95 --- /dev/null +++ b/branding/logo.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake new file mode 100644 index 0000000..604b025 --- /dev/null +++ b/cmake/Dependencies.cmake @@ -0,0 +1,119 @@ +# Third-party code, pinned. Everything is fetched so Windows and Linux build the same way. +include(FetchContent) +set(FETCHCONTENT_QUIET ON) + +set(KEEL_VK_SDK 1.4.363) + +FetchContent_Declare(VulkanHeaders GIT_REPOSITORY https://github.com/KhronosGroup/Vulkan-Headers.git + GIT_TAG vulkan-sdk-${KEEL_VK_SDK}.0 GIT_SHALLOW ON SYSTEM) +FetchContent_Declare(volk GIT_REPOSITORY https://github.com/zeux/volk.git + GIT_TAG vulkan-sdk-${KEEL_VK_SDK}.0 GIT_SHALLOW ON SYSTEM) +FetchContent_Declare(vkbootstrap GIT_REPOSITORY https://github.com/charles-lunarg/vk-bootstrap.git + GIT_TAG v${KEEL_VK_SDK} GIT_SHALLOW ON SYSTEM) +FetchContent_Declare(VulkanMemoryAllocator GIT_REPOSITORY https://github.com/GPUOpen-LibrariesAndSDKs/VulkanMemoryAllocator.git + GIT_TAG v3.3.0 GIT_SHALLOW ON SYSTEM) +# Uses an installed SDL3 if there is one (Nix shell), otherwise builds it. +FetchContent_Declare(SDL3 GIT_REPOSITORY https://github.com/libsdl-org/SDL.git + GIT_TAG release-3.4.18 GIT_SHALLOW ON SYSTEM FIND_PACKAGE_ARGS CONFIG) +FetchContent_Declare(imgui GIT_REPOSITORY https://github.com/ocornut/imgui.git + GIT_TAG v1.92.9b-docking GIT_SHALLOW ON SYSTEM) +FetchContent_Declare(glm GIT_REPOSITORY https://github.com/g-truc/glm.git + GIT_TAG 1.0.3 GIT_SHALLOW ON SYSTEM) +FetchContent_Declare(zstd GIT_REPOSITORY https://github.com/facebook/zstd.git + GIT_TAG v1.5.7 GIT_SHALLOW ON SYSTEM SOURCE_SUBDIR build/cmake) +FetchContent_Declare(cgltf GIT_REPOSITORY https://github.com/jkuhlmann/cgltf.git + GIT_TAG v1.15 GIT_SHALLOW ON SYSTEM SOURCE_SUBDIR no-cmake) +FetchContent_Declare(stb GIT_REPOSITORY https://github.com/nothings/stb.git + GIT_TAG 2c980bb59875b0d32144a71867fbdebb2f77cd20 SYSTEM SOURCE_SUBDIR no-cmake) +FetchContent_Declare(miniaudio GIT_REPOSITORY https://github.com/mackron/miniaudio.git + GIT_TAG 0.11.25 GIT_SHALLOW ON SYSTEM SOURCE_SUBDIR no-cmake) +FetchContent_Declare(ogg GIT_REPOSITORY https://github.com/xiph/ogg.git + GIT_TAG v1.3.6 GIT_SHALLOW ON SYSTEM SOURCE_SUBDIR no-cmake) +FetchContent_Declare(opus GIT_REPOSITORY https://github.com/xiph/opus.git + GIT_TAG v1.6.1 GIT_SHALLOW ON SYSTEM) +FetchContent_Declare(opusfile GIT_REPOSITORY https://github.com/xiph/opusfile.git + GIT_TAG v0.12 GIT_SHALLOW ON SYSTEM SOURCE_SUBDIR no-cmake) +# DejaVu Sans (Bitstream Vera licence): the UI font, close to Source's Verdana look. +FetchContent_Declare(dejavu URL https://github.com/dejavu-fonts/dejavu-fonts/releases/download/version_2_37/dejavu-fonts-ttf-2.37.zip + URL_HASH SHA256=7576310b219e04159d35ff61dd4a4ec4cdba4f35c00e002a136f00e96a908b0a + DOWNLOAD_EXTRACT_TIMESTAMP ON SOURCE_SUBDIR no-cmake) +# KEEL_YAPF_DIR=/path/to/YAPF uses a local checkout instead. +FetchContent_Declare(yapf GIT_REPOSITORY https://github.com/Nexoniarz/yapf.git + GIT_TAG 06a4e7db9e7e6d90132473e5606f0e6f5b1d6001 SYSTEM) # YAPF 2 (+ yapf_tex_to_rgba8: any surface as RGBA8) +if(KEEL_YAPF_DIR) + set(FETCHCONTENT_SOURCE_DIR_YAPF ${KEEL_YAPF_DIR}) +endif() + +set(VOLK_PULL_IN_VULKAN OFF) +set(SDL_SHARED ON) +set(SDL_STATIC OFF) +set(GLM_BUILD_TESTS OFF) +set(ZSTD_BUILD_PROGRAMS OFF) +set(ZSTD_BUILD_SHARED OFF) +set(ZSTD_BUILD_TESTS OFF) +set(ZSTD_LEGACY_SUPPORT OFF) +if(ANDROID) + set(VK_BOOTSTRAP_POSITION_INDEPENDENT_CODE ON CACHE BOOL "" FORCE) # (it sets its own, off unless shared) +endif() +FetchContent_MakeAvailable(VulkanHeaders volk vkbootstrap VulkanMemoryAllocator SDL3 glm zstd cgltf stb) + +# zstd's seekable format (contrib): a normal .zst that also allows random access. YMP packs use it. +add_library(zstd_seekable STATIC + ${zstd_SOURCE_DIR}/contrib/seekable_format/zstdseek_compress.c + ${zstd_SOURCE_DIR}/contrib/seekable_format/zstdseek_decompress.c) +target_include_directories(zstd_seekable SYSTEM PUBLIC ${zstd_SOURCE_DIR}/contrib/seekable_format ${zstd_SOURCE_DIR}/lib) +target_include_directories(zstd_seekable PRIVATE ${zstd_SOURCE_DIR}/lib/common) +target_link_libraries(zstd_seekable PUBLIC libzstd_static) + +add_library(cgltf INTERFACE) +target_include_directories(cgltf SYSTEM INTERFACE ${cgltf_SOURCE_DIR}) +add_library(stb INTERFACE) +target_include_directories(stb SYSTEM INTERFACE ${stb_SOURCE_DIR}) +target_link_libraries(volk PUBLIC Vulkan::Headers) + +# Dear ImGui and YAPF have no CMake of their own; build them here. +FetchContent_MakeAvailable(imgui yapf) +add_library(imgui STATIC + ${imgui_SOURCE_DIR}/imgui.cpp ${imgui_SOURCE_DIR}/imgui_draw.cpp + ${imgui_SOURCE_DIR}/imgui_tables.cpp ${imgui_SOURCE_DIR}/imgui_widgets.cpp + ${imgui_SOURCE_DIR}/imgui_demo.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_sdl3.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_vulkan.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp) +target_include_directories(imgui SYSTEM PUBLIC ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends) +target_compile_definitions(imgui PUBLIC IMGUI_IMPL_VULKAN_USE_VOLK) +target_link_libraries(imgui PUBLIC volk SDL3::SDL3) +if(ANDROID) # its OpenGL backend calls GLES directly there + target_link_libraries(imgui PUBLIC GLESv3 EGL) +endif() + +add_library(yapf STATIC ${yapf_SOURCE_DIR}/yapf.c) +target_include_directories(yapf SYSTEM PUBLIC ${yapf_SOURCE_DIR}) +find_package(Threads REQUIRED) +target_link_libraries(yapf PRIVATE Threads::Threads $<$>:m>) +if(NOT MSVC) + target_compile_options(yapf PRIVATE -O2) # its codecs and the BC7 encoder, even in debug builds +endif() + +# Audio: miniaudio for output; Ogg Opus decoding (opusfile). +set(OPUS_BUILD_TESTING OFF) +set(OPUS_BUILD_PROGRAMS OFF) +set(OPUS_INSTALL_PKG_CONFIG_MODULE OFF) +set(OPUS_INSTALL_CMAKE_CONFIG_MODULE OFF) +FetchContent_MakeAvailable(miniaudio ogg opus opusfile dejavu) +file(WRITE ${CMAKE_BINARY_DIR}/ogg-config/ogg/config_types.h + "#pragma once\n#include \ntypedef int16_t ogg_int16_t;\ntypedef uint16_t ogg_uint16_t;\ntypedef int32_t ogg_int32_t;\ntypedef uint32_t ogg_uint32_t;\ntypedef int64_t ogg_int64_t;\ntypedef uint64_t ogg_uint64_t;\n") +add_library(ogg STATIC ${ogg_SOURCE_DIR}/src/bitwise.c ${ogg_SOURCE_DIR}/src/framing.c) +target_include_directories(ogg SYSTEM PUBLIC ${ogg_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/ogg-config) +add_library(opusfile STATIC ${opusfile_SOURCE_DIR}/src/info.c ${opusfile_SOURCE_DIR}/src/internal.c + ${opusfile_SOURCE_DIR}/src/opusfile.c ${opusfile_SOURCE_DIR}/src/stream.c) +target_include_directories(opusfile SYSTEM PUBLIC ${opusfile_SOURCE_DIR}/include) +target_compile_definitions(opusfile PRIVATE OP_DISABLE_HTTP OP_DISABLE_DOCS) +target_link_libraries(opusfile PUBLIC ogg Opus::opus) +add_library(miniaudio STATIC ${miniaudio_SOURCE_DIR}/miniaudio.c) +target_include_directories(miniaudio SYSTEM PUBLIC ${miniaudio_SOURCE_DIR}) +target_link_libraries(miniaudio PUBLIC ${CMAKE_DL_LIBS} Threads::Threads) +if(UNIX AND NOT APPLE) + target_link_libraries(miniaudio PUBLIC m) +endif() + diff --git a/cmake/EmbedText.cmake b/cmake/EmbedText.cmake new file mode 100644 index 0000000..70f469f --- /dev/null +++ b/cmake/EmbedText.cmake @@ -0,0 +1,22 @@ +# cmake -DOUT=file.inc -DFILES="a;b" -P EmbedText.cmake +# Writes { "name", "text" }, entries as C++ raw string literals, split into chunks so no single +# literal is too long for MSVC. +string(REPLACE "|" ";" FILES "${FILES}") +set(result "") +foreach(f ${FILES}) + get_filename_component(name ${f} NAME) + file(READ ${f} text) + string(LENGTH "${text}" len) + set(literal "") + set(pos 0) + while(pos LESS len) + string(SUBSTRING "${text}" ${pos} 4000 chunk) + string(APPEND literal "R\"keel(${chunk})keel\"\n") + math(EXPR pos "${pos} + 4000") + endwhile() + if(literal STREQUAL "") + set(literal "\"\"") + endif() + string(APPEND result "{\"${name}\",\n${literal}},\n") +endforeach() +file(WRITE ${OUT} "${result}") diff --git a/cmake/Shaders.cmake b/cmake/Shaders.cmake new file mode 100644 index 0000000..873d10b --- /dev/null +++ b/cmake/Shaders.cmake @@ -0,0 +1,29 @@ +# keel_add_shaders(target file...) compiles GLSL to SPIR-V with glslc (KEEL_VULKAN defined) and +# embeds it: `static const uint32_t kSpv[] =\n#include "name.vert.spv.inc"\n;` +# It also embeds every shader's source text (and the shared headers) for the OpenGL backend, +# which compiles them at runtime with KEEL_GL defined: shaders/gl_sources.inc, a list of +# { "name", "source" } entries. +find_program(GLSLC glslc HINTS $ENV{VULKAN_SDK}/bin REQUIRED) + +function(keel_add_shaders target) + set(out_dir ${CMAKE_CURRENT_BINARY_DIR}/shaders) + file(GLOB headers ${CMAKE_CURRENT_SOURCE_DIR}/shaders/*.glsl) + foreach(src ${ARGN}) + get_filename_component(name ${src} NAME) + set(out ${out_dir}/${name}.spv.inc) + add_custom_command(OUTPUT ${out} + COMMAND ${CMAKE_COMMAND} -E make_directory ${out_dir} + COMMAND ${GLSLC} --target-env=vulkan1.3 -DKEEL_VULKAN -O -mfmt=c -I ${CMAKE_CURRENT_SOURCE_DIR}/shaders -o ${out} ${CMAKE_CURRENT_SOURCE_DIR}/${src} + DEPENDS ${src} ${headers} VERBATIM) + list(APPEND outs ${out}) + list(APPEND sources ${CMAKE_CURRENT_SOURCE_DIR}/${src}) + endforeach() + set(gl_out ${out_dir}/gl_sources.inc) + string(REPLACE ";" "|" embed_list "${sources};${headers}") # a list can't pass through COMMAND as one argument + add_custom_command(OUTPUT ${gl_out} + COMMAND ${CMAKE_COMMAND} -E make_directory ${out_dir} + COMMAND ${CMAKE_COMMAND} -DOUT=${gl_out} -DFILES=${embed_list} -P ${PROJECT_SOURCE_DIR}/cmake/EmbedText.cmake + DEPENDS ${sources} ${headers} ${PROJECT_SOURCE_DIR}/cmake/EmbedText.cmake VERBATIM) + target_sources(${target} PRIVATE ${outs} ${gl_out}) + target_include_directories(${target} PRIVATE ${out_dir}) +endfunction() diff --git a/cmake/Version.cmake b/cmake/Version.cmake new file mode 100644 index 0000000..a165798 --- /dev/null +++ b/cmake/Version.cmake @@ -0,0 +1,20 @@ +# Run at build time (cmake -P): writes keel_version.h from git — the commit, its date, and +# whether the tree had changes. Not __DATE__: Nix's build shell pins SOURCE_DATE_EPOCH to 1980 +# (reproducible builds), and that's what the compiler's date said. +execute_process(COMMAND git -C ${SRC} log -1 --format=%h OUTPUT_VARIABLE commit OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) +execute_process(COMMAND git -C ${SRC} log -1 --format=%cd --date=format:%Y-%m-%d\ %H:%M OUTPUT_VARIABLE date OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) +execute_process(COMMAND git -C ${SRC} status --porcelain --untracked-files=no OUTPUT_VARIABLE dirty OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) +if(NOT commit) + set(commit "unknown") + set(date "unknown") +endif() +if(dirty) + set(commit "${commit}+changes") +endif() +set(text "#pragma once\n#define KEEL_COMMIT \"${commit}\"\n#define KEEL_COMMIT_DATE \"${date}\"\n") +if(EXISTS ${OUT}) + file(READ ${OUT} old) +endif() +if(NOT "${old}" STREQUAL "${text}") + file(WRITE ${OUT} "${text}") +endif() diff --git a/content/Fry3DX/branding/icon.png b/content/Fry3DX/branding/icon.png new file mode 100644 index 0000000..bb30490 Binary files /dev/null and b/content/Fry3DX/branding/icon.png differ diff --git a/content/Fry3DX/branding/logo.png b/content/Fry3DX/branding/logo.png new file mode 100644 index 0000000..76db8f9 Binary files /dev/null and b/content/Fry3DX/branding/logo.png differ diff --git a/content/keel/cfg/default.cfg b/content/keel/cfg/default.cfg new file mode 100644 index 0000000..a6c0033 --- /dev/null +++ b/content/keel/cfg/default.cfg @@ -0,0 +1,3 @@ +// Fry3DX: no game keys (the app reads its own). Your settings are saved after this. +unbindall +bind f8 devmenu diff --git a/content/keel/skies/CREDITS.txt b/content/keel/skies/CREDITS.txt new file mode 100644 index 0000000..027f09e --- /dev/null +++ b/content/keel/skies/CREDITS.txt @@ -0,0 +1,4 @@ +skies/day.hdr: "Kloofendal 48d Partly Cloudy (Pure Sky)" by Greg Zaal and Jarod Guest, Poly Haven, CC0 1.0 (public domain) +https://polyhaven.com/a/kloofendal_48d_partly_cloudy_puresky +skies/afternoon.hdr: "Qwantani Afternoon (Pure Sky)" by Greg Zaal and Jarod Guest, Poly Haven, CC0 1.0 (public domain) +https://polyhaven.com/a/qwantani_afternoon_puresky diff --git a/content/keel/skies/day.ksky b/content/keel/skies/day.ksky new file mode 100644 index 0000000..97a2fba Binary files /dev/null and b/content/keel/skies/day.ksky differ diff --git a/docs/content.js b/docs/content.js new file mode 100644 index 0000000..b283f51 --- /dev/null +++ b/docs/content.js @@ -0,0 +1,77 @@ +// Everything the page says. To update the site, change this file only: +// add a feature to `features`, a picture to `shots`, a version to `versions` (newest first). +window.FRY = { + name: "Fry3DX", + long: "Frying 3D Extreme", + author: "Nexoniarz", + authorUrl: "https://github.com/Nexoniarz", + repo: "https://github.com/Nexoniarz/Fry3DX", + tagline: "A 3D modeller that does what you'd expect.", + intro: "Pick a tool, click, drag. Plain words instead of jargon, a toolbox like GIMP's, and the useful stuff from the big modellers without the hundred menus around it.", + + why: { + title: "Why does this exist?", + text: [ + "Because the well-known modellers suck at being easy. You open them and get a wall of panels, shortcuts nobody would ever guess, and modes inside modes. Making a box with a hole for a screw shouldn't need a tutorial.", + "So Nexoniarz made one that's built around how people actually think: pick a tool, click on the thing, drag. Every button says what it does, every tool has a tooltip, and the clever parts (rounding, mirroring, combining, checking a print) are one click away.", + ], + }, + + features: [ + { icon: "▣", title: "Shapes that stay editable", text: "Cube, cylinder, sphere, torus, stairs, arch and more, plus starter models like a table, chair or barrel. Change their sizes any time in Properties." }, + { icon: "✎", title: "Draw a shape anywhere", text: "Click an outline on the ground or straight on the face of another object: it becomes a new part, gets added to it, or cuts into it." }, + { icon: "⊕", title: "Add, subtract, overlap", text: "Combine objects: add them together, take one out of another, or keep only where they overlap. The result is always closed and ready to print." }, + { icon: "◫", title: "Real shape editing", text: "Points, edges and faces. Extrude, inset, round edges, loop cut, knife, merge. Shift+click picks a whole loop, Shift+Ctrl+click a ring." }, + { icon: "◎", title: "Holes for screws and nuts", text: "Round, rectangle, triangle or hexagon. M2 to M8 screw and nut sizes ready, with a little room added so the print actually fits." }, + { icon: "▤", title: "Copies and thickness", text: "Copies in a row or round in a circle. Make a surface thick, or a solid hollow with walls exactly as thick as you need." }, + { icon: "🖌", title: "Paint and stickers", text: "Paint on the model or on its flat picture. Paste any picture (Ctrl+V) and it goes on like a sticker that bends over edges and corners." }, + { icon: "▦", title: "Layout tab", text: "Move, turn and size the flat frames of the surface by hand. The paint follows, so the model looks the same." }, + { icon: "⎙", title: "Made for 3D printing", text: "Your printer's plate in the view, sizes in millimetres, checks for holes and flipped faces with fix buttons, overhangs shown red, weight and filament." }, + { icon: "☀", title: "See it rendered", text: "Solid view for modelling, a real renderer with sky, sun and soft shadows when you want to see it properly. Save a picture with F12." }, + { icon: "🗀", title: "Folders and groups", text: "Layers with folders inside folders, drag and drop, show/hide and lock whole groups, and a button on every row that brings the camera to it." }, + { icon: "💾", title: "Never lose work", text: "Its own packed .fry file, autosave in the background, recovery after a crash, and a Save / Don't save / Cancel when closing." }, + ], + + shots: [ + { img: "img/model.webp", title: "Model", text: "Starter models, a folder of parts, a shape drawn onto a cube's face, rendered with sky and shadows." }, + { img: "img/paint.webp", title: "Paint", text: "A picture pasted on as a sticker: it bends over the cube's edge like a real one." }, + { img: "img/print.webp", title: "Print", text: "The printer's plate, sizes in millimetres, checks and overhangs before you slice." }, + { img: "img/combine.webp", title: "Combine", text: "A cylinder taken out of a cube, copies in a row in a folder, a plane made thick." }, + { img: "img/crash.webp", title: "Even the crash screen", text: "If something does get burnt, your work comes back from the last autosave." }, + ], + + formats: { + open: [".fry"], + save: [".fry (packed, with pictures)"], + export: ["GLB", "glTF", "FBX", "OBJ + MTL", "3MF", "STL", "PLY"], + note: "FBX opens in Blender, Unity, Unreal and Maya. 3MF and STL are in millimetres for slicers (PrusaSlicer, Bambu Studio, Cura, OrcaSlicer).", + }, + + keys: [ + ["Q W E R", "Select, move, turn, scale"], + ["Tab", "Edit an object's shape"], + ["D", "Draw a shape"], + ["O", "Make a hole"], + ["Ctrl+G", "Group into a folder"], + ["Ctrl+Shift + / −", "Add together / take out"], + ["Ctrl+1 … 5", "Model, Paint, Layout, Render, Print"], + ["F1", "Every key, in the app"], + ], + + build: { + text: "Fry3DX builds from source on Linux with CMake. Everything else it needs is downloaded while building. On NixOS, shell.nix has the tools.", + steps: [ + "git clone https://github.com/Nexoniarz/Fry3DX.git", + "cd Fry3DX", + "nix-shell --run 'cmake --preset release && cmake --build build/release'", + "build/release/bin/fry3dx", + ], + needs: "Needs: a Vulkan graphics card, CMake, Ninja, a C++20 compiler, glslc (shaderc) and SDL3.", + }, + + versions: [ + { v: "0.4", date: "2026-10-19", items: ["Combine objects: add, subtract, overlap", "Draw shapes onto other objects", "Copies in a row or circle, thicken and hollow", "FBX export", "Folders and groups in Layers, go-to buttons", "Shift scales both sides, Shift+click loops and rings", "A crash screen of its own"] }, + { v: "0.3", date: "2026-10-19", items: ["Stickers that bend over edges, picture layers", "Packed .fry files, autosave and recovery", "Layout tab, Print tab, 3MF / STL / OBJ / PLY / GLB export", "Hole tool: hexagon nut pockets, rectangles, outlines"] }, + { v: "0.1", date: "2026-10-19", items: ["First version: shapes, shape editing, knife, painting, rendering"] }, + ], +}; diff --git a/docs/img/combine.webp b/docs/img/combine.webp new file mode 100644 index 0000000..2e66415 Binary files /dev/null and b/docs/img/combine.webp differ diff --git a/docs/img/crash.webp b/docs/img/crash.webp new file mode 100644 index 0000000..4fc98d2 Binary files /dev/null and b/docs/img/crash.webp differ diff --git a/docs/img/icon.png b/docs/img/icon.png new file mode 100644 index 0000000..bb30490 Binary files /dev/null and b/docs/img/icon.png differ diff --git a/docs/img/logo.svg b/docs/img/logo.svg new file mode 100644 index 0000000..2ca9d95 --- /dev/null +++ b/docs/img/logo.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/img/model.webp b/docs/img/model.webp new file mode 100644 index 0000000..829b27e Binary files /dev/null and b/docs/img/model.webp differ diff --git a/docs/img/paint.webp b/docs/img/paint.webp new file mode 100644 index 0000000..b8b63e8 Binary files /dev/null and b/docs/img/paint.webp differ diff --git a/docs/img/print.webp b/docs/img/print.webp new file mode 100644 index 0000000..b23e10d Binary files /dev/null and b/docs/img/print.webp differ diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..fdddae0 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,86 @@ + + + + + + Fry3DX + + + + + + + + + +
+ Fry3DX + +
+ +
+
+
+

+

+

+ +
+
Fry3DX with a table, chair, barrel and crate
+
+ +
+ +
+

What it does

+
+
+ +
+

How it looks

+
+
+
+ +
+
+

Files

+
+
+
+

Keys that make sense

+
+
+
+ +
+

Get it

+

+
+

+
+ +
+

Versions

+
+
+
+ +
+

Fry3DX, made by .

+

Sky pictures from Poly Haven (CC0).

+
+ + + + + diff --git a/docs/site.js b/docs/site.js new file mode 100644 index 0000000..9952e8a --- /dev/null +++ b/docs/site.js @@ -0,0 +1,91 @@ +// Builds the page from window.FRY (content.js). Nothing here needs changing for a content update. +(function () { + const F = window.FRY; + const $ = (id) => document.getElementById(id); + const el = (tag, cls, text) => { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; + }; + + $("long").textContent = F.long; + $("tagline").textContent = F.tagline; + $("intro").textContent = F.intro; + for (const id of ["gh-top", "gh-hero"]) $(id).href = F.repo; + $("author").textContent = F.author; + $("author").href = F.authorUrl; + + const why = $("why"); + why.append(el("h2", null, F.why.title)); + for (const p of F.why.text) why.append(el("p", null, p)); + + const grid = $("feature-grid"); + for (const f of F.features) { + const card = el("article", "card"); + card.append(el("div", "icon", f.icon), el("h3", null, f.title), el("p", null, f.text)); + grid.append(card); + } + + // Screenshots: a tab each. + const tabs = $("shot-tabs"); + const show = (i) => { + const s = F.shots[i]; + $("shot-img").src = s.img; + $("shot-img").alt = s.title; + $("shot-text").textContent = s.text; + [...tabs.children].forEach((b, j) => b.setAttribute("aria-selected", i === j)); + }; + F.shots.forEach((s, i) => { + const b = el("button", "tab", s.title); + b.setAttribute("role", "tab"); + b.onclick = () => show(i); + tabs.append(b); + new Image().src = s.img; // (loaded ahead, so switching is instant) + }); + show(0); + + const fm = $("formats"); + const row = (label, items) => { + const d = el("div", "fmt"); + d.append(el("span", "fmt-label", label)); + for (const x of items) d.append(el("span", "chip", x)); + fm.append(d); + }; + row("Save", F.formats.save); + row("Export", F.formats.export); + fm.append(el("p", "muted", F.formats.note)); + + const keys = $("keys"); + for (const [k, what] of F.keys) { + const tr = el("tr"); + const td = el("td"); + td.append(el("kbd", null, k)); + tr.append(td, el("td", null, what)); + keys.append(tr); + } + + $("build-text").textContent = F.build.text; + $("build-steps").textContent = F.build.steps.join("\n"); + $("build-needs").textContent = F.build.needs; + $("copy").onclick = async () => { + try { + await navigator.clipboard.writeText(F.build.steps.join("\n")); + $("copy").textContent = "Copied"; + } catch { + $("copy").textContent = "Select and copy"; + } + setTimeout(() => ($("copy").textContent = "Copy"), 1800); + }; + + const vl = $("version-list"); + for (const v of F.versions) { + const d = el("div", "version"); + const h = el("h3"); + h.append(el("span", "v", "v" + v.v), el("span", "muted", " " + v.date)); + const ul = el("ul"); + for (const it of v.items) ul.append(el("li", null, it)); + d.append(h, ul); + vl.append(d); + } +})(); diff --git a/docs/style.css b/docs/style.css new file mode 100644 index 0000000..6799fc1 --- /dev/null +++ b/docs/style.css @@ -0,0 +1,103 @@ +:root { + --bg: #161618; + --panel: #1f1f23; + --panel2: #26262b; + --line: #34343b; + --ink: #ecebe6; + --dim: #9c9a94; + --hot: #ff7828; + --hot2: #ffc24a; + --radius: 10px; + color-scheme: dark; +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} +a { color: var(--hot); text-decoration: none; } +a:hover { text-decoration: underline; } +img { max-width: 100%; display: block; } +h1, h2, h3 { line-height: 1.2; margin: 0 0 .6em; } +h2 { font-size: 1.7rem; } +h2::after { content: ""; display: block; width: 48px; height: 4px; margin-top: .4em; border-radius: 2px; background: linear-gradient(90deg, var(--hot), var(--hot2)); } +.muted { color: var(--dim); } + +.top { + position: sticky; top: 0; z-index: 10; + display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 10px max(16px, calc((100% - 1120px) / 2)); + background: rgba(22, 22, 24, .9); backdrop-filter: blur(8px); + border-bottom: 1px solid var(--line); +} +.brand { display: flex; align-items: center; gap: 10px; color: var(--ink); font-weight: 700; font-size: 1.15rem; } +.brand img { width: 32px; height: 32px; } +.brand:hover { text-decoration: none; } +nav { display: flex; gap: 18px; flex-wrap: wrap; justify-content: flex-end; } +nav a { color: var(--dim); font-size: .95rem; } +nav a:hover { color: var(--ink); text-decoration: none; } +nav .gh { color: var(--hot); } + +main { max-width: 1120px; margin: 0 auto; padding: 0 16px; } +section { padding: 56px 0; border-bottom: 1px solid var(--line); } +section:last-child { border-bottom: 0; } + +.hero { display: grid; grid-template-columns: 1fr 1.25fr; gap: 40px; align-items: center; padding-top: 64px; } +.kicker { color: var(--hot); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; font-size: .85rem; margin: 0 0 .6em; } +.hero h1 { font-size: clamp(2rem, 4.5vw, 3.2rem); } +.lead { color: var(--dim); font-size: 1.1rem; } +.buttons { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 24px; } +.btn { display: inline-block; padding: 12px 20px; border-radius: var(--radius); background: var(--panel2); color: var(--ink); font-weight: 600; border: 1px solid var(--line); } +.btn:hover { text-decoration: none; border-color: var(--hot); } +.btn.hot { background: linear-gradient(135deg, var(--hot), #ff4a1c); border-color: transparent; color: #fff; } +.hero-shot { margin: 0; } +.hero-shot img, .shot img { border-radius: var(--radius); border: 1px solid var(--line); box-shadow: 0 20px 60px rgba(0, 0, 0, .45); } + +.why { max-width: 760px; } +.why p { font-size: 1.08rem; color: #cfcdc7; } + +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; } +.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; transition: border-color .15s, transform .15s; } +.card:hover { border-color: var(--hot); transform: translateY(-2px); } +.card .icon { font-size: 1.5rem; color: var(--hot); margin-bottom: 6px; } +.card h3 { font-size: 1.05rem; } +.card p { margin: 0; color: var(--dim); font-size: .95rem; } + +.tabs { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; } +.tab { font: inherit; color: var(--dim); background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: 6px 16px; cursor: pointer; } +.tab:hover { color: var(--ink); } +.tab[aria-selected="true"] { color: #fff; background: var(--hot); border-color: var(--hot); } +.shot { margin: 0; } +.shot figcaption { color: var(--dim); margin-top: 12px; } + +.two { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; } +.fmt { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; } +.fmt-label { width: 64px; color: var(--dim); } +.chip { background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; padding: 2px 10px; font-size: .9rem; } +.keys { border-collapse: collapse; width: 100%; } +.keys td { padding: 7px 0; border-bottom: 1px solid var(--line); } +.keys td:first-child { width: 45%; } +kbd { font: .85rem ui-monospace, "DejaVu Sans Mono", monospace; background: var(--panel2); border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px; padding: 2px 7px; } + +.code { position: relative; background: #0f0f11; border: 1px solid var(--line); border-left: 3px solid var(--hot); border-radius: var(--radius); } +.code pre { margin: 0; padding: 18px 20px; overflow-x: auto; font: .92rem/1.7 ui-monospace, "DejaVu Sans Mono", monospace; color: #d6d4cd; } +.copy { position: absolute; top: 10px; right: 10px; font: inherit; font-size: .85rem; color: var(--ink); background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; padding: 4px 12px; cursor: pointer; } +.copy:hover { border-color: var(--hot); } + +.version { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px 22px; margin-bottom: 12px; } +.version h3 { margin-bottom: .3em; } +.version .v { color: var(--hot); } +.version ul { margin: 0; padding-left: 20px; color: #cfcdc7; } + +footer { text-align: center; padding: 40px 16px; border-top: 1px solid var(--line); color: var(--dim); } +footer p { margin: 4px 0; } + +@media (max-width: 820px) { + .hero, .two { grid-template-columns: 1fr; } + .hero { padding-top: 32px; gap: 28px; } + nav a:not(.gh) { display: none; } + section { padding: 40px 0; } +} diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt new file mode 100644 index 0000000..1bf5f9a --- /dev/null +++ b/engine/CMakeLists.txt @@ -0,0 +1,29 @@ +# Fry3DX's engine, what a desktop tool needs: window, input, Vulkan/OpenGL renderer, ImGui, console and cvars, files, +# audio. No networking, physics, scripts or game framework. +add_library(keel STATIC + core/log.cpp core/cvar.cpp core/console.cpp core/sysinfo.cpp + fs/ymp.cpp fs/vfs.cpp + audio/audio.cpp + platform/window.cpp platform/input.cpp platform/touch.cpp + rhi/vulkan/vk_device.cpp rhi/vulkan/vk_backend.cpp + rhi/gl/gl_loader.cpp rhi/gl/gl_backend.cpp rhi/gl/gl_legacy.cpp + render/renderer.cpp render/resources.cpp render/clusters.cpp render/screenshot.cpp + ui/ui.cpp + engine.cpp crash.cpp) +target_include_directories(keel PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +add_custom_target(keel_version COMMAND ${CMAKE_COMMAND} -DSRC=${PROJECT_SOURCE_DIR} -DOUT=${CMAKE_BINARY_DIR}/generated/keel_version.h + -P ${PROJECT_SOURCE_DIR}/cmake/Version.cmake BYPRODUCTS ${CMAKE_BINARY_DIR}/generated/keel_version.h VERBATIM) +add_dependencies(keel keel_version) +target_include_directories(keel PRIVATE ${CMAKE_BINARY_DIR}/generated) +target_link_libraries(keel PRIVATE stb miniaudio opusfile) +target_link_libraries(keel PUBLIC zstd_seekable glm::glm yapf SDL3::SDL3 volk vk-bootstrap::vk-bootstrap GPUOpen::VulkanMemoryAllocator imgui) +target_compile_definitions(keel PUBLIC GLM_FORCE_DEPTH_ZERO_TO_ONE GLM_ENABLE_EXPERIMENTAL VK_NO_PROTOTYPES) +keel_add_shaders(keel shaders/box.vert shaders/box.frag shaders/mesh.vert shaders/mesh.frag shaders/depth.frag shaders/cull.comp shaders/froxel.comp shaders/froxel_rt.comp shaders/gi.comp shaders/skinned.vert + shaders/fullscreen.vert shaders/tonemap.frag shaders/sky.vert shaders/sky.frag shaders/smoke_sky.frag shaders/hzb.frag + shaders/ssao.frag shaders/ssao_blur.frag shaders/bloom_down.frag shaders/bloom_up.frag shaders/taa.frag shaders/ssr.frag shaders/adapt.frag shaders/volumetric.frag shaders/sprite.vert shaders/sprite.frag) +if(MSVC) + target_compile_options(keel PRIVATE /W4) +else() + target_compile_options(keel PRIVATE -Wall -Wextra -Wno-missing-field-initializers) +endif() +target_precompile_headers(keel PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/core/pch.h) diff --git a/engine/asset/kmap.h b/engine/asset/kmap.h new file mode 100644 index 0000000..d6e8c2f --- /dev/null +++ b/engine/asset/kmap.h @@ -0,0 +1,177 @@ +#pragma once +// KMAP: a compiled map, written by assetc from a TrenchBroom .map file. +// KmapHeader +// render model: a complete KMDL blob (renderModelSize bytes) +// KmapBrush × brushCount, then glm-compatible float[3] × pointCount +// entity text (entitySize bytes), Quake style: { "key" "value" ... } ... +// light probes: float × 19 per probe (ambient cube: 6 × rgb for +X -X +Y -Y +Z -Z, then sun visibility) +// material names, '\n'-separated (materialTableSize bytes): what KmapBrush::material indexes +#include "asset/kmdl.h" + +#include + +#include +#include + +namespace keel { + +inline constexpr char kKmapMagic[4] = {'K', 'M', 'A', 'P'}; +inline constexpr uint32_t kKmapVersion = 5; + +enum BrushContents : uint32_t { + CONTENTS_SOLID = 1 << 0, + CONTENTS_PLAYERCLIP = 1 << 1, // blocks players only (tools/clip) + CONTENTS_TRIGGER = 1 << 2, // not solid; reports players inside (trigger_* entities) +}; + +inline constexpr uint32_t kWorldEntity = 0xFFFFFFFF; + +struct KmapHeader { + char magic[4]; + uint32_t version; + uint32_t renderModelSize; + uint32_t brushCount, pointCount; + uint32_t entitySize; + float probeOrigin[3]; + float probeSpacing; + uint32_t probeDims[3]; + uint32_t materialTableSize; +}; + +inline constexpr int kProbeFloats = 19; + +// Baked lighting for moving things: a grid of ambient cubes (like Source). +struct KmapProbes { + glm::vec3 origin{0.0f}; + float spacing = 0.0f; + glm::uvec3 dims{0u}; + std::vector data; // kProbeFloats per probe, x fastest +}; + +struct KmapBrush { + uint32_t firstPoint, pointCount; + uint32_t contents; + uint32_t entity; // index into entities, or kWorldEntity + // Texture of its top, sides and bottom (into KmapData::materials): what you walk on, + // run into, bump your head on. Sounds and effects look up the surface from it. + uint16_t material[3]; + uint16_t pad; +}; +enum BrushFace { FACE_TOP = 0, FACE_SIDE = 1, FACE_BOTTOM = 2 }; + +using EntityKeys = std::map; + +struct KmapData { + KmdlModel render; + KmapProbes probes; + std::vector brushes; + std::vector materials; + std::vector points; // xyz triples + std::vector entities; +}; + +// Parses `{ "k" "v" }` blocks. Shared by the compiler (for .map entity keys) and loader. +inline std::vector parseEntityText(const std::string& text) { + std::vector out; + size_t i = 0; + auto quoted = [&](std::string& s) { + while (i < text.size() && text[i] != '"' && text[i] != '}') ++i; + if (i >= text.size() || text[i] == '}') return false; + const size_t end = text.find('"', i + 1); + if (end == std::string::npos) return false; + s = text.substr(i + 1, end - i - 1); + i = end + 1; + return true; + }; + while ((i = text.find('{', i)) != std::string::npos) { + ++i; + EntityKeys e; + std::string k, v; + while (quoted(k) && quoted(v)) e[k] = v; + out.push_back(std::move(e)); + i = text.find('}', i); + if (i == std::string::npos) break; + } + return out; +} + +inline std::string entityText(const std::vector& entities) { + std::string out; + for (const auto& e : entities) { + out += "{\n"; + for (const auto& [k, v] : e) out += "\"" + k + "\" \"" + v + "\"\n"; + out += "}\n"; + } + return out; +} + +inline std::vector writeKmap(const KmapData& m) { + const std::vector render = writeKmdl(m.render); + const std::string ents = entityText(m.entities); + KmapHeader h{}; + std::memcpy(h.magic, kKmapMagic, 4); + h.version = kKmapVersion; + h.renderModelSize = uint32_t(render.size()); + h.brushCount = uint32_t(m.brushes.size()); + h.pointCount = uint32_t(m.points.size() / 3); + h.entitySize = uint32_t(ents.size()); + std::memcpy(h.probeOrigin, &m.probes.origin, 12); + h.probeSpacing = m.probes.spacing; + std::memcpy(h.probeDims, &m.probes.dims, 12); + std::string table; + for (const std::string& name : m.materials) table += name + "\n"; + h.materialTableSize = uint32_t(table.size()); + std::vector out; + auto put = [&](const void* p, size_t n) { out.insert(out.end(), (const uint8_t*)p, (const uint8_t*)p + n); }; + put(&h, sizeof h); + put(render.data(), render.size()); + put(m.brushes.data(), m.brushes.size() * sizeof(KmapBrush)); + put(m.points.data(), m.points.size() * sizeof(float)); + put(ents.data(), ents.size()); + put(m.probes.data.data(), m.probes.data.size() * sizeof(float)); + put(table.data(), table.size()); + return out; +} + +inline std::optional readKmap(const std::vector& data) { + KmapHeader h; + if (data.size() < sizeof h) return std::nullopt; + std::memcpy(&h, data.data(), sizeof h); + if (std::memcmp(h.magic, kKmapMagic, 4) != 0 || h.version != kKmapVersion) return std::nullopt; + const uint64_t need = sizeof h + uint64_t(h.renderModelSize) + uint64_t(h.brushCount) * sizeof(KmapBrush) + + uint64_t(h.pointCount) * 12 + h.entitySize; + if (data.size() < need) return std::nullopt; + KmapData m; + size_t pos = sizeof h; + auto render = readKmdl(std::vector(data.begin() + pos, data.begin() + pos + h.renderModelSize)); + if (h.renderModelSize && !render) return std::nullopt; + if (render) m.render = std::move(*render); + pos += h.renderModelSize; + m.brushes.resize(h.brushCount); + std::memcpy(m.brushes.data(), data.data() + pos, h.brushCount * sizeof(KmapBrush)); + pos += h.brushCount * sizeof(KmapBrush); + m.points.resize(size_t(h.pointCount) * 3); + std::memcpy(m.points.data(), data.data() + pos, m.points.size() * sizeof(float)); + pos += m.points.size() * sizeof(float); + for (const KmapBrush& b : m.brushes) + if (uint64_t(b.firstPoint) + b.pointCount > h.pointCount) return std::nullopt; + m.entities = parseEntityText(std::string((const char*)data.data() + pos, h.entitySize)); + pos += h.entitySize; + const uint64_t probes = uint64_t(h.probeDims[0]) * h.probeDims[1] * h.probeDims[2]; + if (probes > 4'000'000 || data.size() < pos + probes * kProbeFloats * 4) return std::nullopt; + std::memcpy(&m.probes.origin, h.probeOrigin, 12); + m.probes.spacing = h.probeSpacing; + std::memcpy(&m.probes.dims, h.probeDims, 12); + m.probes.data.resize(size_t(probes) * kProbeFloats); + std::memcpy(m.probes.data.data(), data.data() + pos, m.probes.data.size() * 4); + pos += m.probes.data.size() * 4; + if (data.size() < pos + h.materialTableSize) return std::nullopt; + const std::string table((const char*)data.data() + pos, h.materialTableSize); + for (size_t a = 0, b; (b = table.find('\n', a)) != std::string::npos; a = b + 1) m.materials.push_back(table.substr(a, b - a)); + for (const KmapBrush& b : m.brushes) + for (uint16_t mat : b.material) + if (mat >= m.materials.size() && !m.materials.empty()) return std::nullopt; + return m; +} + +} // namespace keel diff --git a/engine/asset/kmdl.h b/engine/asset/kmdl.h new file mode 100644 index 0000000..6ce1434 --- /dev/null +++ b/engine/asset/kmdl.h @@ -0,0 +1,200 @@ +#pragma once +// KMDL: the engine's runtime model format, written by assetc from glTF. Laid out so +// the loader is a few memcpys: +// KmdlHeader +// KmdlMaterial × materialCount +// KmdlSubmesh × submeshCount +// KmdlVertex × vertexCount +// uint32_t × indexCount +// if jointCount > 0: +// KmdlJoint × jointCount (parents before children) +// KmdlSkin × vertexCount +// animationCount × { KmdlAnimation, then per channel: KmdlChannel, +// float times[keyCount], float values[keyCount × 3 or 4] } +// hullCount × { uint32_t pointCount, float xyz[pointCount × 3] } +// Hulls are the collision model (like Source's VPhysics .phy): convex pieces, model space. +// None = collide as the convex hull of all vertices. +#include +#include +#include +#include + +namespace keel { + +inline constexpr char kKmdlMagic[4] = {'K', 'M', 'D', 'L'}; +inline constexpr uint32_t kKmdlVersion = 5; +// KmdlHeader::flags +inline constexpr uint32_t KMDL_PLAYER_SOLID = 1u; // players collide with it whatever its size (doors) + +struct KmdlHeader { + char magic[4]; + uint32_t version; + uint32_t materialCount, submeshCount, vertexCount, indexCount; + float boundsMin[3], boundsMax[3]; + uint32_t jointCount, animationCount; + uint32_t hullCount; + uint32_t flags; +}; + +struct KmdlJoint { + char name[32]; + int32_t parent; // -1 = root + float inverseBind[16]; // column-major + float translation[3], rotation[4], scale[3]; // rest pose, local; rotation xyzw +}; + +struct KmdlSkin { + uint8_t joints[4]; + uint8_t weights[4]; // sum to 255 +}; + +enum KmdlChannelPath : uint32_t { KMDL_TRANSLATION = 0, KMDL_ROTATION = 1, KMDL_SCALE = 2 }; + +struct KmdlAnimationHeader { + char name[32]; + float duration; + uint32_t channelCount; +}; + +struct KmdlChannelHeader { + uint32_t joint, path, keyCount; +}; + +struct KmdlChannel { + KmdlChannelHeader header; + std::vector times, values; // values: 3 per key (T, S) or 4 (R, xyzw) +}; + +struct KmdlAnimation { + KmdlAnimationHeader header; + std::vector channels; +}; + +struct KmdlMaterial { + char texture[120]; // game path to a .yapf, empty = white + char lightmap[120]; // baked lighting (RGBM .yapf, uses KmdlVertex::lightmapUv); empty = lit at runtime + float baseColor[4]; +}; + +struct KmdlSubmesh { + uint32_t firstIndex, indexCount, material; +}; + +struct KmdlVertex { + float position[3]; + float normal[3]; + float uv[2]; + float lightmapUv[2]; +}; + +struct KmdlModel { + KmdlHeader header{}; + std::vector materials; + std::vector submeshes; + std::vector vertices; + std::vector indices; + std::vector joints; + std::vector skin; // one per vertex when skinned + std::vector animations; + std::vector> hulls; // xyz triples +}; + +inline std::vector writeKmdl(KmdlModel m) { + std::memcpy(m.header.magic, kKmdlMagic, 4); + m.header.version = kKmdlVersion; + m.header.materialCount = uint32_t(m.materials.size()); + m.header.submeshCount = uint32_t(m.submeshes.size()); + m.header.vertexCount = uint32_t(m.vertices.size()); + m.header.indexCount = uint32_t(m.indices.size()); + m.header.jointCount = uint32_t(m.joints.size()); + m.header.animationCount = uint32_t(m.animations.size()); + m.header.hullCount = uint32_t(m.hulls.size()); + std::vector out; + auto put = [&](const void* p, size_t n) { out.insert(out.end(), (const uint8_t*)p, (const uint8_t*)p + n); }; + put(&m.header, sizeof m.header); + put(m.materials.data(), m.materials.size() * sizeof(KmdlMaterial)); + put(m.submeshes.data(), m.submeshes.size() * sizeof(KmdlSubmesh)); + put(m.vertices.data(), m.vertices.size() * sizeof(KmdlVertex)); + put(m.indices.data(), m.indices.size() * sizeof(uint32_t)); + if (!m.joints.empty()) { + put(m.joints.data(), m.joints.size() * sizeof(KmdlJoint)); + put(m.skin.data(), m.skin.size() * sizeof(KmdlSkin)); + for (auto& a : m.animations) { + a.header.channelCount = uint32_t(a.channels.size()); + put(&a.header, sizeof a.header); + for (auto& c : a.channels) { + c.header.keyCount = uint32_t(c.times.size()); + put(&c.header, sizeof c.header); + put(c.times.data(), c.times.size() * sizeof(float)); + put(c.values.data(), c.values.size() * sizeof(float)); + } + } + } + for (const auto& h : m.hulls) { + const uint32_t n = uint32_t(h.size() / 3); + put(&n, 4); + put(h.data(), size_t(n) * 3 * sizeof(float)); + } + return out; +} + +inline std::optional readKmdl(const std::vector& data) { + KmdlModel m; + size_t pos = 0; + auto take = [&](void* p, size_t n) { + if (pos + n > data.size()) return false; + std::memcpy(p, data.data() + pos, n); + pos += n; + return true; + }; + if (!take(&m.header, sizeof m.header) || std::memcmp(m.header.magic, kKmdlMagic, 4) != 0 || m.header.version != kKmdlVersion) + return std::nullopt; + m.materials.resize(m.header.materialCount); + m.submeshes.resize(m.header.submeshCount); + m.vertices.resize(m.header.vertexCount); + m.indices.resize(m.header.indexCount); + if (!take(m.materials.data(), m.materials.size() * sizeof(KmdlMaterial)) || + !take(m.submeshes.data(), m.submeshes.size() * sizeof(KmdlSubmesh)) || + !take(m.vertices.data(), m.vertices.size() * sizeof(KmdlVertex)) || + !take(m.indices.data(), m.indices.size() * sizeof(uint32_t))) + return std::nullopt; + for (uint32_t i : m.indices) + if (i >= m.vertices.size()) return std::nullopt; + if (m.header.jointCount) { + if (m.header.jointCount > 255) return std::nullopt; + m.joints.resize(m.header.jointCount); + m.skin.resize(m.header.vertexCount); + if (!take(m.joints.data(), m.joints.size() * sizeof(KmdlJoint)) || !take(m.skin.data(), m.skin.size() * sizeof(KmdlSkin))) + return std::nullopt; + for (size_t j = 0; j < m.joints.size(); ++j) + if (m.joints[j].parent >= int32_t(j)) return std::nullopt; // parents first + for (const KmdlSkin& sk : m.skin) + for (uint8_t j : sk.joints) + if (j >= m.joints.size()) return std::nullopt; + m.animations.resize(m.header.animationCount); + for (auto& a : m.animations) { + if (!take(&a.header, sizeof a.header) || a.header.channelCount > 4096) return std::nullopt; + a.channels.resize(a.header.channelCount); + for (auto& c : a.channels) { + if (!take(&c.header, sizeof c.header) || c.header.joint >= m.joints.size() || c.header.path > 2 || c.header.keyCount > 1'000'000) + return std::nullopt; + c.times.resize(c.header.keyCount); + c.values.resize(size_t(c.header.keyCount) * (c.header.path == KMDL_ROTATION ? 4 : 3)); + if (!take(c.times.data(), c.times.size() * 4) || !take(c.values.data(), c.values.size() * 4)) return std::nullopt; + } + } + } + if (m.header.hullCount > 4096) return std::nullopt; + m.hulls.resize(m.header.hullCount); + for (auto& h : m.hulls) { + uint32_t n = 0; + if (!take(&n, 4) || n > 65536) return std::nullopt; + h.resize(size_t(n) * 3); + if (!take(h.data(), h.size() * sizeof(float))) return std::nullopt; + } + for (auto& s : m.submeshes) + if (uint64_t(s.firstIndex) + s.indexCount > m.indices.size() || s.material >= m.materials.size()) return std::nullopt; + return m; +} + +} // namespace keel diff --git a/engine/asset/ksky.h b/engine/asset/ksky.h new file mode 100644 index 0000000..0a1836b --- /dev/null +++ b/engine/asset/ksky.h @@ -0,0 +1,25 @@ +#pragma once +// .ksky: a sky for the renderer, compiled by assetc from an equirectangular .hdr. +// KskyHeader +// `levels` images of width >> k × height >> k RGBM8 pixels (rows top-down), largest first +// Directions are world space (+Z up): u = atan2(d.y, d.x) / 2π + 0.5, v = acos(d.z) / π. +// A pixel's linear radiance is rgb · a · kSkyRange. The sky is normalised so its upper half +// averages 1 (the engine scales it by the map's sky intensity), and the sun is taken out of +// it (the engine draws its own, matching the shadows). Level k is the sky as a surface of +// roughness k / (levels - 1) reflects it (GGX prefiltered), for image-based reflections. +#include + +namespace keel { + +inline constexpr uint32_t kKskyVersion = 1; +inline constexpr float kSkyRange = 16.0f; + +struct KskyHeader { + char magic[4]; // "KSKY" + uint32_t version; + uint32_t width, height, levels; + float sunDir[3]; // towards where the sun was in the photo + float sh[27]; // 9 RGB spherical harmonics of the light it casts: Σ shᵢ·Yᵢ(n) = irradiance / π +}; + +} // namespace keel diff --git a/engine/audio/audio.cpp b/engine/audio/audio.cpp new file mode 100644 index 0000000..f83ce4b --- /dev/null +++ b/engine/audio/audio.cpp @@ -0,0 +1,334 @@ +#include "audio/audio.h" +#include "core/cvar.h" +#include "core/log.h" +#include "fs/vfs.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace keel::audio { +namespace { +Cvar s_volume("volume", "0.8", CVAR_ARCHIVE, "Master volume 0..1"); +Cvar s_musicvolume("s_musicvolume", "0.3", CVAR_ARCHIVE, "Music volume 0..1"); + +using Bytes = std::shared_ptr>; + +// ── Ogg Opus as a miniaudio data source, decoding from memory as it plays ── +struct OpusSource { + ma_data_source_base base; + Bytes bytes; + OggOpusFile* file = nullptr; + int channels = 1; +}; + +ma_result opusRead(ma_data_source* ds, void* out, ma_uint64 frames, ma_uint64* read) { + auto* s = static_cast(ds); + ma_uint64 done = 0; + float* dst = static_cast(out); + while (done < frames) { + const int n = s->channels == 2 ? op_read_float_stereo(s->file, dst + done * 2, int((frames - done) * 2)) + : op_read_float(s->file, dst + done, int(frames - done), nullptr); + if (n <= 0) break; + done += ma_uint64(n); + } + *read = done; + return done == 0 && frames > 0 ? MA_AT_END : MA_SUCCESS; +} +ma_result opusSeek(ma_data_source* ds, ma_uint64 frame) { + return op_pcm_seek(static_cast(ds)->file, ogg_int64_t(frame)) == 0 ? MA_SUCCESS : MA_ERROR; +} +ma_result opusFormat(ma_data_source* ds, ma_format* fmt, ma_uint32* channels, ma_uint32* rate, ma_channel* map, size_t mapCap) { + *fmt = ma_format_f32; + *channels = ma_uint32(static_cast(ds)->channels); + *rate = 48000; // Opus always decodes at 48 kHz + if (map) ma_channel_map_init_standard(ma_standard_channel_map_default, map, mapCap, *channels); + return MA_SUCCESS; +} +ma_result opusCursor(ma_data_source* ds, ma_uint64* cursor) { + const ogg_int64_t t = op_pcm_tell(static_cast(ds)->file); + *cursor = t < 0 ? 0 : ma_uint64(t); + return MA_SUCCESS; +} +ma_result opusLength(ma_data_source* ds, ma_uint64* length) { + const ogg_int64_t t = op_pcm_total(static_cast(ds)->file, -1); + *length = t < 0 ? 0 : ma_uint64(t); + return MA_SUCCESS; +} +const ma_data_source_vtable kOpusVtable = {opusRead, opusSeek, opusFormat, opusCursor, opusLength, nullptr, 0}; + +// A playing sound and whatever decodes it. +struct Voice { + ma_sound sound{}; + OpusSource opus{}; + ma_decoder decoder{}; + bool isOpus = false, hasDecoder = false, music = false; + Bytes bytes; + ~Voice() { + ma_sound_uninit(&sound); + if (isOpus) { + op_free(opus.file); + ma_data_source_uninit(&opus.base); + } + if (hasDecoder) ma_decoder_uninit(&decoder); + } +}; + +std::unique_ptr g_engine; +std::list g_voices; +std::unordered_map g_cache; // file bytes, shared by voices +std::unordered_map g_times; // their files' times (hot reload) +Voice* g_music = nullptr; +std::unordered_map g_ambience; // looping background sounds by path +struct Loop { + Voice* voice; + std::string path; + int frame; // the update it was last set in +}; +std::unordered_map g_loops; // looping sounds in the world (setLoop) +int g_frame = 0; + +Bytes load(std::string_view path) { + const std::string key(path); + if (auto it = g_cache.find(key); it != g_cache.end()) return it->second; + auto data = fs::read(path); + if (!data) log::warn("Sound not found: {}", key); + if (auto t = fs::mtime(key)) g_times[key] = *t; + return g_cache[key] = data ? std::make_shared>(std::move(*data)) : nullptr; +} + +Voice* start(std::string_view path, bool spatial) { + Bytes bytes = load(path); + if (!g_engine || !bytes) return nullptr; + Voice& v = g_voices.emplace_back(); + v.bytes = bytes; + ma_data_source* source = nullptr; + if (path.ends_with(".opus") || path.ends_with(".ogg")) { + int err = 0; + v.opus.file = op_open_memory(bytes->data(), bytes->size(), &err); + if (!v.opus.file) { + log::warn("{}: not an Ogg Opus file ({})", path, err); + g_voices.pop_back(); + return nullptr; + } + v.isOpus = true; + v.opus.channels = std::min(op_channel_count(v.opus.file, -1), 2); + ma_data_source_config cfg = ma_data_source_config_init(); + cfg.vtable = &kOpusVtable; + ma_data_source_init(&cfg, &v.opus.base); + source = &v.opus.base; + } else { + if (ma_decoder_init_memory(bytes->data(), bytes->size(), nullptr, &v.decoder) != MA_SUCCESS) { + log::warn("{}: unsupported audio file", path); + g_voices.pop_back(); + return nullptr; + } + v.hasDecoder = true; + source = &v.decoder; + } + const ma_uint32 flags = spatial ? 0 : MA_SOUND_FLAG_NO_SPATIALIZATION; + if (ma_sound_init_from_data_source(g_engine.get(), source, flags, nullptr, &v.sound) != MA_SUCCESS) { + g_voices.pop_back(); + return nullptr; + } + if (spatial) { + ma_sound_set_attenuation_model(&v.sound, ma_attenuation_model_inverse); + ma_sound_set_min_distance(&v.sound, 80.0f); + ma_sound_set_max_distance(&v.sound, 3000.0f); + } + return &v; +} + +Cmd cmd_play("play", [](const Args& a) { + if (a.size() > 1) play(a[1]); +}, "Play a sound file"); +} // namespace + +bool init(bool noDevice) { + g_engine = std::make_unique(); + ma_engine_config cfg = ma_engine_config_init(); + if (noDevice) { + cfg.noDevice = MA_TRUE; + cfg.channels = 2; + cfg.sampleRate = 48000; + } + if (ma_engine_init(&cfg, g_engine.get()) != MA_SUCCESS) { + log::warn("No audio device; sound is off"); + g_engine.reset(); + return false; + } + ma_engine_listener_set_world_up(g_engine.get(), 0, 0, 0, 1); + return true; +} + +void shutdown() { + g_music = nullptr; + g_ambience.clear(); + g_loops.clear(); + g_voices.clear(); + g_cache.clear(); + if (g_engine) ma_engine_uninit(g_engine.get()); + g_engine.reset(); +} + +bool active() { return g_engine != nullptr; } + +Cvar snd_mute_losefocus("snd_mute_losefocus", "1", CVAR_ARCHIVE, "Silence it while its window isn't focused"); +bool g_unfocused = false; +float masterVolume() { return g_unfocused && snd_mute_losefocus.b() ? 0.0f : std::clamp(s_volume.f(), 0.0f, 1.0f); } + +void setFocused(bool focused) { + g_unfocused = !focused; + if (g_engine) ma_engine_set_volume(g_engine.get(), masterVolume()); +} + +void update(const glm::vec3& listener, float yaw, float pitch) { + if (!g_engine) return; + ma_engine_set_volume(g_engine.get(), masterVolume()); + const float y = glm::radians(yaw), p = glm::radians(pitch); + ma_engine_listener_set_position(g_engine.get(), 0, listener.x, listener.y, listener.z); + ma_engine_listener_set_direction(g_engine.get(), 0, std::cos(p) * std::cos(y), std::cos(p) * std::sin(y), -std::sin(p)); + if (g_music) ma_sound_set_volume(&g_music->sound, std::clamp(s_musicvolume.f(), 0.0f, 1.0f)); + g_voices.remove_if([](Voice& v) { return !v.music && ma_sound_at_end(&v.sound); }); + // Loops nobody set lately (their car went away) stop. + ++g_frame; + for (auto it = g_loops.begin(); it != g_loops.end();) { + if (g_frame - it->second.frame > 60) { + Voice* v = it->second.voice; + g_voices.remove_if([&](Voice& x) { return &x == v; }); + it = g_loops.erase(it); + } else ++it; + } +} + +void setLoop(int key, std::string_view path, const glm::vec3& position, float volume, float pitch) { + auto it = g_loops.find(key); + if (it != g_loops.end() && (volume <= 0.001f || it->second.path != path)) { + Voice* v = it->second.voice; + g_voices.remove_if([&](Voice& x) { return &x == v; }); + g_loops.erase(it); + it = g_loops.end(); + } + if (volume <= 0.001f) return; + if (it == g_loops.end()) { + Voice* v = start(path, true); + if (!v) return; + v->music = true; // (kept when it ends; it loops anyway) + ma_sound_set_looping(&v->sound, MA_TRUE); + ma_sound_start(&v->sound); + it = g_loops.emplace(key, Loop{v, std::string(path), g_frame}).first; + } + Loop& l = it->second; + l.frame = g_frame; + ma_sound_set_position(&l.voice->sound, position.x, position.y, position.z); + ma_sound_set_volume(&l.voice->sound, volume); + ma_sound_set_pitch(&l.voice->sound, std::clamp(pitch, 0.1f, 4.0f)); +} + +// Variants: "foo.opus" missing but "foo1.opus", "foo2.opus"... there → one of those at random. +std::string pickVariant(std::string_view path) { + static std::unordered_map counts; // 0 = use the path as is + static std::mt19937 rng{std::random_device{}()}; + const std::string key(path); + auto it = counts.find(key); + if (it == counts.end()) { + int n = 0; + if (!fs::exists(key)) { + const size_t dot = key.rfind('.'); + const std::string stem = key.substr(0, dot), ext = dot == std::string::npos ? "" : key.substr(dot); + while (fs::exists(stem + std::to_string(n + 1) + ext)) ++n; + } + it = counts.emplace(key, n).first; + } + if (it->second == 0) return key; + const size_t dot = key.rfind('.'); + return key.substr(0, dot) + std::to_string(std::uniform_int_distribution(1, it->second)(rng)) + + (dot == std::string::npos ? "" : key.substr(dot)); +} + +std::vector reloadChanged() { + std::vector changed; + for (auto it = g_cache.begin(); it != g_cache.end();) { + const auto t = fs::mtime(it->first); + const auto known = g_times.find(it->first); + if (t && known != g_times.end() && known->second != *t) { + changed.push_back(it->first); + g_times.erase(known); + it = g_cache.erase(it); // playing voices keep their bytes; the next play reads the new file + } else { + ++it; + } + } + return changed; +} + +void play(std::string_view path, std::optional position, float volume) { + if (g_voices.size() > 128) return; // something is spamming; drop rather than stutter + Voice* v = start(pickVariant(path), position.has_value()); + if (!v) return; + if (position) ma_sound_set_position(&v->sound, position->x, position->y, position->z); + ma_sound_set_volume(&v->sound, volume); + // A slightly different pitch each time, so repeated sounds don't sound copy-pasted. + static std::mt19937 jitter{12345}; + ma_sound_set_pitch(&v->sound, std::uniform_real_distribution(0.95f, 1.05f)(jitter)); + ma_sound_start(&v->sound); +} + +void playMusic(std::string_view path, bool loop, int fadeMs) { + stopMusic(); + g_music = start(path, false); + if (!g_music) return; + g_music->music = true; + ma_sound_set_looping(&g_music->sound, loop); + ma_sound_set_volume(&g_music->sound, std::clamp(s_musicvolume.f(), 0.0f, 1.0f)); + if (fadeMs > 0) ma_sound_set_fade_in_milliseconds(&g_music->sound, 0.0f, 1.0f, ma_uint64(fadeMs)); + ma_sound_start(&g_music->sound); +} + +void stopMusic() { + if (!g_music) return; + g_voices.remove_if([](Voice& v) { return &v == g_music; }); + g_music = nullptr; +} + +void setAmbience(std::string_view path, float volume) { + const std::string key(path); + auto it = g_ambience.find(key); + if (volume <= 0.001f) { + if (it != g_ambience.end()) { + Voice* v = it->second; + g_voices.remove_if([&](Voice& x) { return &x == v; }); + g_ambience.erase(it); + } + return; + } + if (it == g_ambience.end()) { + Voice* v = start(path, false); + if (!v) return; + v->music = true; // (kept when it ends; it loops anyway) + ma_sound_set_looping(&v->sound, MA_TRUE); + ma_sound_set_volume(&v->sound, 0.0f); + ma_sound_start(&v->sound); + it = g_ambience.emplace(key, v).first; + } + const float now = ma_sound_get_volume(&it->second->sound); + ma_sound_set_volume(&it->second->sound, now + (volume - now) * 0.08f); +} + +int playingCount() { return int(g_voices.size()); } + +void stopSounds() { g_voices.remove_if([](Voice& v) { return !v.music; }); } + +void readFrames(float* out, int frames) { + if (g_engine) ma_engine_read_pcm_frames(g_engine.get(), out, ma_uint64(frames), nullptr); +} + +} // namespace keel::audio diff --git a/engine/audio/audio.h b/engine/audio/audio.h new file mode 100644 index 0000000..506348e --- /dev/null +++ b/engine/audio/audio.h @@ -0,0 +1,45 @@ +#pragma once +#include +#include +// Sound output (miniaudio). Files load through the VFS: .opus (Ogg Opus, what +// assetc produces and the game ships) plus .wav/.flac/.mp3 for convenience. +// Everything streams, so long music costs no more memory than a short sound. +// World space matches the renderer: Z up, game units. +#include + +#include +#include + +namespace keel::audio { + +// noDevice: mix without an output device (tests, headless); read with readFrames. +bool init(bool noDevice = false); +void shutdown(); +bool active(); + +// Call once per frame: places the listener and frees finished sounds. +void update(const glm::vec3& listener, float yaw, float pitch); +// Silences everything while the window is in the background (snd_mute_losefocus). +void setFocused(bool focused); + +// One-shot sound; with a position it's 3D, otherwise it plays flat (UI, local). +// Hot reload: sounds whose files changed are read again on their next play. Returns their paths. +std::vector reloadChanged(); +void play(std::string_view path, std::optional position = std::nullopt, float volume = 1.0f); +// fadeMs: rises from silence over that long. +void playMusic(std::string_view path, bool loop = true, int fadeMs = 0); +void stopMusic(); +// A looping background sound (rain, wind) at a volume, set every frame or when it changes; +// 0 stops it. It eases to the new volume rather than jumping. +void setAmbience(std::string_view path, float volume); +// A looping sound at a place (a car's engine), by key: set it every frame, with its volume and +// pitch (1 as recorded); volume 0 stops it. Keys not set for a second stop by themselves. +void setLoop(int key, std::string_view path, const glm::vec3& position, float volume, float pitch); +// Stops every sound playing (not the music): an editor's Stop. +void stopSounds(); +int playingCount(); + +// Headless mixing (init(true)): stereo float frames at 48 kHz. +void readFrames(float* out, int frames); + +} // namespace keel::audio diff --git a/engine/core/console.cpp b/engine/core/console.cpp new file mode 100644 index 0000000..3104ec7 --- /dev/null +++ b/engine/core/console.cpp @@ -0,0 +1,309 @@ +// The developer console, Source-style: it drops down from the top; as you type, a list under +// the input shows the commands and cvars that match (cvars with their values) and, once a +// command is typed, what its argument can be (map names for "map", files for "exec", ...). +// Arrows walk the list (writing the pick into the input, like Source), Tab completes, Enter +// runs. History survives restarts. +#include "core/console.h" +#include "core/cvar.h" +#include "core/log.h" + +#include + +#include +#include +#include +#include +#include + +namespace keel::console { +namespace { +bool g_open = false; +bool g_focus = false; +float g_slide = 0.0f; // 0 = up, 1 = down +char g_input[512] = {}; +std::vector g_history; +int g_historyPos = -1; +std::string g_historyFile; + +struct Suggestion { + std::string text; // the whole input it stands for + std::string label; // what's shown: the name or argument + std::string value; // cvars: their value + std::string help; + bool pick = true; // false: an info row (a cvar's description) +}; +std::vector g_list; +int g_sel = -1; +std::string g_typed; // what the user typed (the list is built from it, not from picks) +std::string g_pending; // replace the input with this on the next callback (a click) +bool g_hasPending = false; +constexpr size_t kMaxSuggestions = 14; + +bool containsNoCase(std::string_view hay, std::string_view needle) { + auto low = [](char c) { return char(c >= 'A' && c <= 'Z' ? c + 32 : c); }; + return std::search(hay.begin(), hay.end(), needle.begin(), needle.end(), [&](char a, char b) { return low(a) == low(b); }) != hay.end(); +} + +void describe(Suggestion& s, const std::string& name) { + if (Cmd* c = Cmd::find(name)) s.help = c->help(); + else if (Cvar* v = Cvar::find(name)) s.value = v->str(), s.help = v->help(); + else + for (const auto& [n, text] : aliases()) + if (n == name) s.help = "alias: " + text; +} + +void buildList() { + g_list.clear(); + g_sel = -1; + const std::string& in = g_typed; + // Only the last command of a "a; b; c" line. + const size_t semi = in.rfind(';'); + std::string head = semi == std::string::npos ? "" : in.substr(0, semi + 1) + " "; + std::string seg = semi == std::string::npos ? in : in.substr(semi + 1); + seg.erase(0, seg.find_first_not_of(' ')); + if (semi != std::string::npos) head = in.substr(0, in.size() - seg.size()); + if (seg.empty()) return; + const size_t space = seg.find(' '); + if (space == std::string::npos) { + // A name: those starting with it first, then those containing it. + const std::vector names = allNames(); + for (int pass = 0; pass < 2 && g_list.size() < kMaxSuggestions; ++pass) + for (const std::string& n : names) { + const bool prefix = n.starts_with(seg); + if (pass == 0 ? !prefix : (prefix || !containsNoCase(n, seg))) continue; + Suggestion s{head + n + " ", n}; + describe(s, n); + g_list.push_back(std::move(s)); + if (g_list.size() >= kMaxSuggestions) break; + } + return; + } + // An argument: the command's own suggestions, or what a cvar is. + const std::string name = seg.substr(0, space); + const size_t lastSpace = seg.rfind(' '); + const std::string partial = seg.substr(lastSpace + 1); + const std::string before = head + seg.substr(0, lastSpace + 1); + if (Cmd* c = Cmd::find(name); c && c->completer()) { + const size_t arg = tokenize(seg.substr(0, lastSpace)).size(); // the command is token 0 + for (const std::string& v : c->completer()(partial, arg)) { + Suggestion s{before + v + " ", v}; + if (name == "help" || name == "reset" || name == "toggle" || name == "set" || name == "incrementvar") describe(s, v); + g_list.push_back(std::move(s)); + if (g_list.size() >= kMaxSuggestions) break; + } + } else if (Cvar* v = Cvar::find(name)) { + Suggestion s{"", name, v->str(), std::string(v->help()) + " (default \"" + v->defaultValue() + "\")"}; + s.pick = false; + g_list.push_back(std::move(s)); + } else if (Cmd* c2 = Cmd::find(name)) { + Suggestion s{"", name, "", c2->help()}; + s.pick = false; + g_list.push_back(std::move(s)); + } +} + +void setInput(ImGuiInputTextCallbackData* data, const std::string& text) { + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, text.c_str()); +} + +int inputCallback(ImGuiInputTextCallbackData* data) { + switch (data->EventFlag) { + case ImGuiInputTextFlags_CallbackAlways: + if (g_hasPending) { + setInput(data, g_pending); + g_hasPending = false; + } + break; + case ImGuiInputTextFlags_CallbackEdit: + g_typed.assign(data->Buf, size_t(data->BufTextLen)); + g_historyPos = -1; + buildList(); + break; + case ImGuiInputTextFlags_CallbackHistory: { + const bool up = data->EventKey == ImGuiKey_UpArrow; + int picks = 0; + for (const auto& s : g_list) picks += s.pick; + if (picks > 0 && !g_typed.empty()) { // walk the suggestions + int i = g_sel; + do i = up ? (i <= 0 ? int(g_list.size()) - 1 : i - 1) : (i + 1) % int(g_list.size()); + while (!g_list[size_t(i)].pick); + g_sel = i; + setInput(data, g_list[size_t(i)].text); + } else if (!g_history.empty()) { // or the history + if (up) g_historyPos = g_historyPos < 0 ? int(g_history.size()) - 1 : std::max(0, g_historyPos - 1); + else if (g_historyPos >= 0 && ++g_historyPos >= int(g_history.size())) g_historyPos = -1; + setInput(data, g_historyPos >= 0 ? g_history[size_t(g_historyPos)] : ""); + } + break; + } + case ImGuiInputTextFlags_CallbackCompletion: { + // Tab: the picked suggestion, else the first, else the longest common start. + std::vector picks; + for (const auto& s : g_list) + if (s.pick) picks.push_back(&s); + if (picks.empty()) break; + const Suggestion* s = g_sel >= 0 && g_list[size_t(g_sel)].pick ? &g_list[size_t(g_sel)] : picks.front(); + setInput(data, s->text); + g_typed = s->text; + buildList(); + break; + } + default: + break; + } + return 0; +} + +void remember(const std::string& line) { + if (g_history.empty() || g_history.back() != line) g_history.push_back(line); + if (g_history.size() > 200) g_history.erase(g_history.begin()); + if (g_historyFile.empty()) return; + std::ofstream out(g_historyFile); + for (const auto& h : g_history) out << h << '\n'; +} +} // namespace + +namespace { +Cmd cmd_toggleconsole("toggleconsole", [](const Args&) { toggle(); }, "Open or close the console"); +Cmd cmd_con_input("con_input", [](const Args& a) { + std::string text; + for (size_t i = 1; i < a.size(); ++i) text += (i > 1 ? " " : "") + a[i]; + if (a.size() > 1 && a.back().empty()) text += " "; + std::snprintf(g_input, sizeof g_input, "%s", text.c_str()); + g_pending = text; // the input box keeps its own copy while focused + g_hasPending = true; + g_typed = text; + buildList(); + if (!g_open) toggle(); +}, "con_input : open the console with this typed (scripts, tests)"); +} // namespace + +void toggle() { + g_open = !g_open; + g_focus = g_open; +} +bool isOpen() { return g_open; } + +void setHistoryFile(const std::string& path) { + g_historyFile = path; + g_history.clear(); + std::ifstream in(path); + for (std::string line; std::getline(in, line);) + if (!line.empty()) g_history.push_back(line); +} + +void draw() { + const float dt = ImGui::GetIO().DeltaTime; + g_slide = std::clamp(g_slide + (g_open ? dt : -dt) * 7.0f, 0.0f, 1.0f); + if (g_slide <= 0.0f) return; + const float k = 1.0f - (1.0f - g_slide) * (1.0f - g_slide); // ease out + const ImGuiViewport* vp = ImGui::GetMainViewport(); + const float fullH = vp->WorkSize.y * 0.46f; + const ImU32 accent = IM_COL32(226, 54, 54, 255); + ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x, vp->WorkPos.y - fullH * (1.0f - k))); + ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, fullH)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, IM_COL32(14, 13, 15, 238)); + ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(255, 255, 255, 10)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(14, 10)); + ImGui::Begin("Console", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings); + const ImVec2 wp = ImGui::GetWindowPos(), ws = ImGui::GetWindowSize(); + ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(wp.x, wp.y + ws.y - 2), ImVec2(wp.x + ws.x, wp.y + ws.y), accent); + + const float footer = ImGui::GetFrameHeightWithSpacing() + 4; + ImGui::BeginChild("lines", ImVec2(0, -footer)); + for (const log::Line& line : log::history()) { + const bool echo = line.text.starts_with("] "); + const ImVec4 color = line.level == log::Level::Error ? ImVec4(1, .42f, .42f, 1) + : line.level == log::Level::Warn ? ImVec4(1, .78f, .35f, 1) + : echo ? ImVec4(.95f, .55f, .5f, 1) + : ImVec4(.86f, .85f, .83f, 1); + ImGui::TextColored(color, "%s", line.text.c_str()); + } + if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) ImGui::SetScrollHereY(1.0f); + ImGui::EndChild(); + + // The input, with a prompt. + ImGui::TextColored(ImVec4(.89f, .21f, .21f, 1), ">"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1); + if (g_focus) { + ImGui::SetKeyboardFocusHere(); + g_focus = false; + } + const ImVec2 inputPos = ImGui::GetCursorScreenPos(); + const auto flags = ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackHistory | ImGuiInputTextFlags_CallbackCompletion | + ImGuiInputTextFlags_CallbackEdit | ImGuiInputTextFlags_CallbackAlways; + if (ImGui::InputText("##input", g_input, sizeof g_input, flags, inputCallback)) { + std::string line = g_input; + while (!line.empty() && line.back() == ' ') line.pop_back(); + if (!line.empty()) { + log::info("] {}", line); + remember(line); + execute(line); + } + g_input[0] = 0; + g_typed.clear(); + g_list.clear(); + g_sel = -1; + g_historyPos = -1; + ImGui::SetKeyboardFocusHere(-1); + } + if (ImGui::IsItemActive() && ImGui::IsKeyPressed(ImGuiKey_L) && ImGui::GetIO().KeyCtrl) log::clearHistory(); + const float inputH = ImGui::GetItemRectSize().y; + ImGui::End(); + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(2); + + // The suggestions, under the console like Source's. + if (g_list.empty() || g_slide < 1.0f || !g_open) return; + ImGui::SetNextWindowPos(ImVec2(inputPos.x, inputPos.y + inputH + 6)); + ImGui::SetNextWindowBgAlpha(0.96f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, IM_COL32(22, 20, 23, 245)); + ImGui::PushStyleColor(ImGuiCol_Border, IM_COL32(226, 54, 54, 120)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 6)); + ImGui::Begin("##suggestions", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoNav); + float nameW = 0, valueW = 0; + for (const auto& s : g_list) { + nameW = std::max(nameW, ImGui::CalcTextSize(s.label.c_str()).x); + valueW = std::max(valueW, s.value.empty() ? 0.0f : ImGui::CalcTextSize(("\"" + s.value + "\"").c_str()).x); + } + const float maxW = vp->WorkSize.x - (inputPos.x - vp->WorkPos.x) - 40; + for (int i = 0; i < int(g_list.size()); ++i) { + const Suggestion& s = g_list[size_t(i)]; + ImGui::PushID(i); + const ImVec2 p = ImGui::GetCursorScreenPos(); + const float rowW = std::min(maxW, nameW + valueW + 520); + if (s.pick) { + if (ImGui::Selectable("##row", g_sel == i, 0, ImVec2(rowW, 0))) { + g_pending = s.text; + g_hasPending = true; + g_typed = s.text; + buildList(); + g_focus = true; + } + } else { + ImGui::Dummy(ImVec2(rowW, ImGui::GetTextLineHeight())); + } + ImDrawList* dl = ImGui::GetWindowDrawList(); + if (g_sel == i) dl->AddRectFilled(p, ImVec2(p.x + 3, p.y + ImGui::GetTextLineHeight()), accent); + dl->AddText(ImVec2(p.x + 8, p.y), IM_COL32(240, 236, 228, 255), s.label.c_str()); + float x = p.x + 8 + nameW + 16; + if (!s.value.empty()) dl->AddText(ImVec2(x, p.y), IM_COL32(240, 120, 110, 255), ("\"" + s.value + "\"").c_str()); + x += valueW + (valueW > 0 ? 16 : 0); + if (!s.help.empty()) { + ImGui::PushClipRect(ImVec2(x, p.y), ImVec2(p.x + rowW, p.y + 40), true); + dl->AddText(ImVec2(x, p.y), IM_COL32(150, 145, 138, 255), s.help.c_str()); + ImGui::PopClipRect(); + } + ImGui::PopID(); + } + ImGui::End(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(2); +} + +} // namespace keel::console diff --git a/engine/core/console.h b/engine/core/console.h new file mode 100644 index 0000000..ec82c76 --- /dev/null +++ b/engine/core/console.h @@ -0,0 +1,13 @@ +#pragma once +#include + +namespace keel::console { + +void toggle(); +bool isOpen(); +// Draws the console window with Dear ImGui. Call between ImGui::NewFrame and ImGui::Render. +void draw(); +// Where typed lines are kept between runs (loaded now, saved as you type). +void setHistoryFile(const std::string& path); + +} // namespace keel::console diff --git a/engine/core/cvar.cpp b/engine/core/cvar.cpp new file mode 100644 index 0000000..6251834 --- /dev/null +++ b/engine/core/cvar.cpp @@ -0,0 +1,322 @@ +#include "core/cvar.h" +#include "core/log.h" + +#include +#include +#include +#include +#include + +namespace keel { +namespace { +// Function-local statics: safe to use from other files' static initializers. +std::vector& cvars() { static std::vector v; return v; } +std::vector& cmds() { static std::vector v; return v; } +std::vector>& aliasList() { static std::vector> v; return v; } +int g_aliasDepth = 0; // an alias that runs itself stops here + +template T* findByName(std::vector& list, std::string_view name) { + auto it = std::find_if(list.begin(), list.end(), [&](T* x) { return name == x->name(); }); + return it == list.end() ? nullptr : *it; +} +} // namespace + +Cvar::Cvar(const char* name, const char* default_value, unsigned flags, const char* help) + : name_(name), help_(help), default_(default_value), flags_(flags) { + set(default_value); + cvars().push_back(this); +} + +Cvar::~Cvar() { std::erase(cvars(), this); } + +void Cvar::set(std::string_view value) { + value_ = value; +#if defined(__ANDROID__) // (the NDK's library has no float from_chars; bionic's locale is always "C") + const std::string text(value); + char* end = nullptr; + const float parsed = std::strtof(text.c_str(), &end); + float_ = end != text.c_str() ? parsed : 0.0f; +#else + float parsed = 0.0f; + auto [_, ec] = std::from_chars(value.data(), value.data() + value.size(), parsed); + float_ = ec == std::errc() ? parsed : 0.0f; +#endif + modified_ = true; +} + +bool Cvar::consumeModified() { return std::exchange(modified_, false); } +Cvar* Cvar::find(std::string_view name) { return findByName(cvars(), name); } +const std::vector& Cvar::all() { return cvars(); } + +Cmd::Cmd(const char* name, Fn fn, const char* help) : name_(name), help_(help), fn_(std::move(fn)) { + cmds().push_back(this); +} + +Cmd::~Cmd() { std::erase(cmds(), this); } + +Cmd* Cmd::find(std::string_view name) { return findByName(cmds(), name); } +const std::vector& Cmd::all() { return cmds(); } + +Args tokenize(std::string_view line) { + Args out; + size_t i = 0; + while (i < line.size()) { + while (i < line.size() && line[i] <= ' ') ++i; + if (i >= line.size()) break; + if (line[i] == '"') { + size_t end = line.find('"', ++i); + if (end == std::string_view::npos) end = line.size(); + out.emplace_back(line.substr(i, end - i)); + i = end + 1; + } else { + size_t start = i; + while (i < line.size() && line[i] > ' ') ++i; + out.emplace_back(line.substr(start, i - start)); + } + } + return out; +} + +namespace { +struct Deferred { + int frames; + std::string text; +}; +std::vector g_deferred; +} // namespace + +void executeDeferred() { + std::vector due; + for (auto it = g_deferred.begin(); it != g_deferred.end();) + if (--it->frames <= 0) { + due.push_back(std::move(*it)); + it = g_deferred.erase(it); + } else { + ++it; + } + for (const Deferred& d : due) execute(d.text); +} + +void execute(std::string_view text) { + // Split on ';' and newlines outside quotes. + bool quoted = false; + size_t start = 0; + for (size_t i = 0; i <= text.size(); ++i) { + if (i < text.size() && text[i] == '"') quoted = !quoted; + if (i < text.size() && (quoted || (text[i] != ';' && text[i] != '\n'))) continue; + + Args args = tokenize(text.substr(start, i - start)); + start = i + 1; + if (args.empty() || args[0].starts_with("//")) continue; + if (args[0] == "wait") { + const int frames = args.size() > 1 ? std::max(std::atoi(args[1].c_str()), 1) : 1; + if (i < text.size()) g_deferred.push_back({frames, std::string(text.substr(i + 1))}); + return; + } + + auto alias = std::find_if(aliasList().begin(), aliasList().end(), [&](const auto& a) { return a.first == args[0]; }); + if (Cmd* cmd = Cmd::find(args[0])) { + cmd->run(args); + } else if (alias != aliasList().end()) { + if (g_aliasDepth > 32) { + log::warn("alias {} runs itself too deeply", args[0]); + continue; + } + ++g_aliasDepth; + execute(alias->second); + --g_aliasDepth; + } else if (Cvar* cvar = Cvar::find(args[0])) { + if (args.size() == 1) { + log::info("\"{}\" = \"{}\" (default \"{}\"){}", cvar->name(), cvar->str(), cvar->defaultValue(), + cvar->flags() & CVAR_CHEAT ? " cheat" : ""); + log::info(" {}", cvar->help()); + } else if ((cvar->flags() & CVAR_CHEAT) && !Cvar::find("sv_cheats")->b()) { + log::warn("{} is cheat protected", cvar->name()); + } else { + cvar->set(args[1]); + } + } else { + log::warn("Unknown command \"{}\"", args[0]); + } + } +} + +static Cvar sv_cheats("sv_cheats", "0", CVAR_REPLICATE, "Allow cheat-protected cvars"); + +static Cmd cmd_echo("echo", [](const Args& a) { + std::string s; + for (size_t i = 1; i < a.size(); ++i) s += (i > 1 ? " " : "") + a[i]; + log::info("{}", s); +}, "Print text to the console"); + +static Cmd cmd_set("set", [](const Args& a) { + if (a.size() < 3) return log::info("usage: set "); + if (Cvar* c = Cvar::find(a[1])) c->set(a[2]); + else log::warn("Unknown cvar \"{}\"", a[1]); +}, "Set a cvar"); + +const std::vector>& aliases() { return aliasList(); } + +std::vector allNames() { + std::vector out; + for (Cmd* c : Cmd::all()) out.emplace_back(c->name()); + for (Cvar* c : Cvar::all()) out.emplace_back(c->name()); + for (const auto& a : aliasList()) out.push_back(a.first); + std::sort(out.begin(), out.end()); + out.erase(std::unique(out.begin(), out.end()), out.end()); + return out; +} + +std::vector matching(const std::vector& names, std::string_view partial) { + std::vector out; + for (const std::string& n : names) + if (n.starts_with(partial)) out.push_back(n); + return out; +} + +namespace { +std::string flagText(unsigned f) { + std::string s; + if (f & CVAR_ARCHIVE) s += " saved"; + if (f & CVAR_CHEAT) s += " cheat"; + if (f & CVAR_REPLICATE) s += " replicated"; + return s; +} +bool contains(std::string_view hay, std::string_view needle) { + auto low = [](char c) { return char(c >= 'A' && c <= 'Z' ? c + 32 : c); }; + return std::search(hay.begin(), hay.end(), needle.begin(), needle.end(), [&](char a, char b) { return low(a) == low(b); }) != hay.end(); +} +Cmd::Completer names() { + return [](std::string_view p, size_t) { return matching(allNames(), p); }; +} +Cmd::Completer cvarNames() { + return [](std::string_view p, size_t) { + std::vector v; + for (Cvar* c : Cvar::all()) v.emplace_back(c->name()); + std::sort(v.begin(), v.end()); + return matching(v, p); + }; +} +} // namespace + +// toggle [values...]: 0 ↔ 1, or the next of the values given (Source). +static Cmd cmd_toggle("toggle", [](const Args& a) { + if (a.size() < 2) return log::info("usage: toggle [value1 value2 ...]"); + Cvar* c = Cvar::find(a[1]); + if (!c) return log::warn("Unknown cvar \"{}\"", a[1]); + if (a.size() < 4) return execute(a[1] + (c->b() ? " 0" : " 1")); + size_t next = 2; + for (size_t i = 2; i < a.size(); ++i) + if (a[i] == c->str()) next = i + 1 < a.size() ? i + 1 : 2; + execute(a[1] + " \"" + a[next] + "\""); +}, "toggle [values...]: flip between 0 and 1, or step through the values given"); + +static Cmd cmd_incrementvar("incrementvar", [](const Args& a) { + if (a.size() < 5) return log::info("usage: incrementvar "); + Cvar* c = Cvar::find(a[1]); + if (!c) return log::warn("Unknown cvar \"{}\"", a[1]); + const float lo = float(std::atof(a[2].c_str())), hi = float(std::atof(a[3].c_str())), step = float(std::atof(a[4].c_str())); + float v = c->f() + step; + if (v > hi) v = lo; + if (v < lo) v = hi; + execute(std::string(a[1]) + " " + std::to_string(v)); +}, "incrementvar : add step, wrapping round (bind it to keys)"); + +static Cmd cmd_find("find", [](const Args& a) { + if (a.size() < 2) return log::info("usage: find (searches names and descriptions)"); + int n = 0; + for (const std::string& name : allNames()) { + Cmd* cmd = Cmd::find(name); + Cvar* cv = cmd ? nullptr : Cvar::find(name); + const char* help = cmd ? cmd->help() : cv ? cv->help() : ""; + if (!contains(name, a[1]) && !contains(help, a[1])) continue; + if (cv) log::info(" {} = \"{}\" - {}", name, cv->str(), help); + else log::info(" {} - {}", name, cmd ? help : "alias"); + ++n; + } + log::info("{} found", n); +}, "find : commands and cvars whose name or description mentions it"); + +static Cmd cmd_help("help", [](const Args& a) { + if (a.size() < 2) { + log::info("Type a command or cvar name; a list of matches shows as you type (Tab completes, arrows pick)."); + log::info(" help what it does find search names and descriptions"); + log::info(" cvarlist [x] all cvars cmdlist [x] all commands"); + log::info(" differences cvars you changed reset back to its default"); + log::info(" alias, bind, exec, toggle, incrementvar, echo, clear, condump, wait"); + return; + } + if (Cmd* c = Cmd::find(a[1])) return log::info("{} (command) - {}", c->name(), c->help()); + if (Cvar* c = Cvar::find(a[1])) { + log::info("\"{}\" = \"{}\" (default \"{}\"){}", c->name(), c->str(), c->defaultValue(), flagText(c->flags())); + return log::info(" {}", c->help()); + } + for (const auto& [name, text] : aliasList()) + if (name == a[1]) return log::info("{} (alias) = \"{}\"", name, text); + log::warn("Nothing called \"{}\". Try: find {}", a[1], a[1]); +}, "help [name]: how the console works, or what a command/cvar does"); + +static Cmd cmd_cvarlist("cvarlist", [](const Args& a) { + std::vector v(Cvar::all().begin(), Cvar::all().end()); + std::sort(v.begin(), v.end(), [](Cvar* x, Cvar* y) { return std::string_view(x->name()) < y->name(); }); + int n = 0; + for (Cvar* c : v) + if (a.size() < 2 || std::string_view(c->name()).starts_with(a[1])) + log::info(" {:<24} {:<10}{:<9} {}", c->name(), "\"" + c->str() + "\"", flagText(c->flags()), c->help()), ++n; + log::info("{} cvars", n); +}, "cvarlist [prefix]: every cvar, its value and what it does"); + +static Cmd cmd_cmdlist("cmdlist", [](const Args& a) { + std::vector v(Cmd::all().begin(), Cmd::all().end()); + std::sort(v.begin(), v.end(), [](Cmd* x, Cmd* y) { return std::string_view(x->name()) < y->name(); }); + int n = 0; + for (Cmd* c : v) + if (a.size() < 2 || std::string_view(c->name()).starts_with(a[1])) log::info(" {:<20} {}", c->name(), c->help()), ++n; + log::info("{} commands", n); +}, "cmdlist [prefix]: every command and what it does"); + +static Cmd cmd_differences("differences", [](const Args&) { + int n = 0; + for (Cvar* c : Cvar::all()) + if (c->str() != c->defaultValue()) log::info(" {} = \"{}\" (default \"{}\")", c->name(), c->str(), c->defaultValue()), ++n; + log::info("{} changed from their defaults", n); +}, "Cvars that aren't at their default value"); + +static Cmd cmd_reset("reset", [](const Args& a) { + if (a.size() < 2) return log::info("usage: reset "); + if (Cvar* c = Cvar::find(a[1])) c->set(c->defaultValue()), log::info("{} = \"{}\"", c->name(), c->str()); + else log::warn("Unknown cvar \"{}\"", a[1]); +}, "reset : back to its default value"); + +static Cmd cmd_alias("alias", [](const Args& a) { + auto& list = aliasList(); + if (a.size() < 2) { + for (const auto& [name, text] : list) log::info(" {} = \"{}\"", name, text); + return log::info("{} aliases", list.size()); + } + std::erase_if(list, [&](const auto& x) { return x.first == a[1]; }); + if (a.size() < 3) return; + std::string text; + for (size_t i = 2; i < a.size(); ++i) text += (i > 2 ? " " : "") + a[i]; + list.emplace_back(a[1], text); +}, "alias \"commands; more commands\": make a new command (alias alone removes it)"); + +static Cmd cmd_clear("clear", [](const Args&) { log::clearHistory(); }, "Clear the console"); + +static Cmd cmd_condump("condump", [](const Args& a) { + const std::string path = a.size() > 1 ? a[1] : "condump.txt"; + std::FILE* f = std::fopen(path.c_str(), "w"); + if (!f) return log::warn("Can't write {}", path); + for (const log::Line& l : log::history()) std::fprintf(f, "%s\n", l.text.c_str()); + std::fclose(f); + log::info("Console written to {}", path); +}, "condump [file]: save the console's text to a file"); + +// What the console suggests for their arguments. +[[maybe_unused]] static const bool g_completers = [] { + cmd_help.complete(names()); + for (Cmd* c : {&cmd_reset, &cmd_toggle, &cmd_incrementvar, &cmd_set}) c->complete(cvarNames()); + return true; +}(); + +} // namespace keel diff --git a/engine/core/cvar.h b/engine/core/cvar.h new file mode 100644 index 0000000..05b0009 --- /dev/null +++ b/engine/core/cvar.h @@ -0,0 +1,101 @@ +#pragma once +// Quake-style console variables and commands. +// +// static keel::Cvar r_vsync("r_vsync", "1", keel::CVAR_ARCHIVE, "Wait for vertical sync"); +// static keel::Cmd quit("quit", [](const keel::Args&) { ... }, "Quit"); +// +// Both register themselves at static-init time and are reachable from the console. +#include +#include +#include +#include +#include + +namespace keel { + +enum CvarFlags : unsigned { + CVAR_NONE = 0, + CVAR_ARCHIVE = 1 << 0, // saved to config.cfg + CVAR_CHEAT = 1 << 1, // needs sv_cheats 1 + CVAR_REPLICATE = 1 << 2, // server value is sent to clients +}; + +class Cvar { +public: + Cvar(const char* name, const char* default_value, unsigned flags, const char* help); + ~Cvar(); // unregisters (scripts create and destroy cvars at runtime) + Cvar(const Cvar&) = delete; + Cvar& operator=(const Cvar&) = delete; + + const char* name() const { return name_; } + const char* help() const { return help_; } + const std::string& defaultValue() const { return default_; } + unsigned flags() const { return flags_; } + + const std::string& str() const { return value_; } + float f() const { return float_; } + int i() const { return int(float_); } + bool b() const { return float_ != 0.0f; } + + void set(std::string_view value); + // True once after the value changed; lets systems react (e.g. recreate the swapchain). + bool consumeModified(); + + static Cvar* find(std::string_view name); + static const std::vector& all(); + +private: + const char* name_; + const char* help_; + std::string default_; + std::string value_; + float float_ = 0.0f; + unsigned flags_; + bool modified_ = true; +}; + +using Args = std::vector; // args[0] is the command name + +class Cmd { +public: + using Fn = std::function; + // Suggestions for the argument being typed (the console's list): given what's typed of + // it and which argument it is (1 = the first), the values it could be. Set with complete(). + using Completer = std::function(std::string_view partial, size_t arg)>; + Cmd(const char* name, Fn fn, const char* help); + ~Cmd(); + Cmd(const Cmd&) = delete; + Cmd& operator=(const Cmd&) = delete; + + const char* name() const { return name_; } + const char* help() const { return help_; } + void run(const Args& args) const { fn_(args); } + Cmd& complete(Completer c) { completer_ = std::move(c); return *this; } + const Completer& completer() const { return completer_; } + + static Cmd* find(std::string_view name); + static const std::vector& all(); + +private: + const char* name_; + const char* help_; + Fn fn_; + Completer completer_; +}; + +// alias name "commands": a new command that runs them (Quake/Source). +const std::vector>& aliases(); +// Every command, cvar and alias name, sorted (for completion). +std::vector allNames(); +// The names of things in `names` that start with `partial` (completers use it). +std::vector matching(const std::vector& names, std::string_view partial); + +// Runs a line of console text: "cmd args; name value; name". "wait [frames]" defers +// the rest of the line (Quake style), e.g. "map test; wait 120; screenshot". +void execute(std::string_view text); +// Runs text deferred by "wait" whose frames have passed. Called once per frame. +void executeDeferred(); +// Splits a line into arguments, honouring "quoted strings". +Args tokenize(std::string_view line); + +} // namespace keel diff --git a/engine/core/log.cpp b/engine/core/log.cpp new file mode 100644 index 0000000..f265f09 --- /dev/null +++ b/engine/core/log.cpp @@ -0,0 +1,33 @@ +#include "core/log.h" + +#include +#include +#include +#if defined(__ANDROID__) +#include +#endif + +namespace keel::log { +namespace { +constexpr size_t kMaxLines = 4096; +std::mutex g_mutex; +std::vector g_lines; +} // namespace + +void write(Level level, std::string_view text) { + static const char* const prefix[] = {"", "WARN: ", "ERROR: "}; + std::lock_guard lock(g_mutex); + std::fprintf(level == Level::Info ? stdout : stderr, "%s%.*s\n", prefix[int(level)], int(text.size()), text.data()); + std::fflush(level == Level::Info ? stdout : stderr); +#if defined(__ANDROID__) // (a phone has no console: adb logcat -s Fry3DX shows it) + static const int priority[] = {ANDROID_LOG_INFO, ANDROID_LOG_WARN, ANDROID_LOG_ERROR}; + __android_log_print(priority[int(level)], "Fry3DX", "%.*s", int(text.size()), text.data()); +#endif + if (g_lines.size() >= kMaxLines) g_lines.erase(g_lines.begin(), g_lines.begin() + kMaxLines / 4); + g_lines.push_back({level, std::string(text)}); +} + +const std::vector& history() { return g_lines; } +void clearHistory() { g_lines.clear(); } + +} // namespace keel::log diff --git a/engine/core/log.h b/engine/core/log.h new file mode 100644 index 0000000..c8c425e --- /dev/null +++ b/engine/core/log.h @@ -0,0 +1,25 @@ +#pragma once +#include +#include +#include +#include + +namespace keel::log { + +enum class Level { Info, Warn, Error }; + +struct Line { + Level level; + std::string text; +}; + +void write(Level level, std::string_view text); +// The last few thousand lines, for the console. +const std::vector& history(); +void clearHistory(); + +template void info(std::format_string f, A&&... a) { write(Level::Info, std::format(f, std::forward(a)...)); } +template void warn(std::format_string f, A&&... a) { write(Level::Warn, std::format(f, std::forward(a)...)); } +template void error(std::format_string f, A&&... a) { write(Level::Error, std::format(f, std::forward(a)...)); } + +} // namespace keel::log diff --git a/engine/core/pch.h b/engine/core/pch.h new file mode 100644 index 0000000..5b4da45 --- /dev/null +++ b/engine/core/pch.h @@ -0,0 +1,21 @@ +#pragma once +// Precompiled for the engine's libraries (engine/CMakeLists.txt): the standard library and glm +// headers nearly every file pulls in, parsed once per library instead of once per file. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include diff --git a/engine/core/sysinfo.cpp b/engine/core/sysinfo.cpp new file mode 100644 index 0000000..e59f42b --- /dev/null +++ b/engine/core/sysinfo.cpp @@ -0,0 +1,117 @@ +#include "core/sysinfo.h" + +#include +#include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#define PSAPI_VERSION 2 // (K32GetProcessMemoryInfo, in kernel32) +#include +#include +#else +#include +#include +#endif +#if defined(_MSC_VER) +#include +#elif defined(__x86_64__) || defined(__i386__) +#include +#endif + +namespace keel::sys { +namespace { +std::string trim(std::string s) { + while (!s.empty() && (s.back() == ' ' || s.back() == '\n' || s.back() == '\r' || s.back() == '\0')) s.pop_back(); + size_t i = 0; + while (i < s.size() && s[i] == ' ') ++i; + return s.substr(i); +} + +// The CPU's own name for itself (x86: cpuid's brand string; elsewhere /proc/cpuinfo). +std::string cpuName() { +#if defined(_MSC_VER) || defined(__x86_64__) || defined(__i386__) + unsigned regs[12] = {}; + for (unsigned i = 0; i < 3; ++i) { +#if defined(_MSC_VER) + int r[4]; + __cpuid(r, int(0x80000002u + i)); + std::memcpy(regs + i * 4, r, sizeof r); +#else + __get_cpuid(0x80000002u + i, ®s[i * 4], ®s[i * 4 + 1], ®s[i * 4 + 2], ®s[i * 4 + 3]); +#endif + } + char brand[49] = {}; + std::memcpy(brand, regs, 48); + if (brand[0]) return trim(brand); +#endif + std::ifstream in("/proc/cpuinfo"); + for (std::string line; std::getline(in, line);) + for (const char* key : {"model name", "Hardware", "Processor"}) + if (line.rfind(key, 0) == 0) + if (const size_t c = line.find(':'); c != std::string::npos) return trim(line.substr(c + 1)); + return "unknown CPU"; +} + +std::string osName() { +#if defined(_WIN32) + // GetVersionEx lies to programs without a manifest; ntdll's RtlGetVersion doesn't. + using RtlGetVersionFn = LONG(WINAPI*)(PRTL_OSVERSIONINFOW); + RTL_OSVERSIONINFOW v{sizeof v}; + if (auto fn = reinterpret_cast(reinterpret_cast(GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "RtlGetVersion")))) + fn(&v); + const char* name = v.dwMajorVersion == 10 ? (v.dwBuildNumber >= 22000 ? "Windows 11" : "Windows 10") : "Windows"; + return std::string(name) + " (build " + std::to_string(v.dwBuildNumber) + ")"; +#else + utsname u{}; + uname(&u); + std::string pretty; + std::ifstream in("/etc/os-release"); + for (std::string line; std::getline(in, line);) + if (line.rfind("PRETTY_NAME=", 0) == 0) { + pretty = line.substr(12); + if (pretty.size() >= 2 && pretty.front() == '"') pretty = pretty.substr(1, pretty.size() - 2); + } +#if defined(__ANDROID__) + if (pretty.empty()) pretty = "Android"; +#endif + if (pretty.empty()) pretty = u.sysname; + return pretty + " (" + u.sysname + " " + u.release + ")"; +#endif +} +} // namespace + +const Machine& machine() { + static const Machine m{osName(), cpuName(), int(std::thread::hardware_concurrency())}; + return m; +} + +Memory memory() { + Memory m; +#if defined(_WIN32) + MEMORYSTATUSEX s{sizeof s}; + if (GlobalMemoryStatusEx(&s)) m.total = s.ullTotalPhys, m.used = s.ullTotalPhys - s.ullAvailPhys; + PROCESS_MEMORY_COUNTERS pmc{sizeof pmc}; + if (K32GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof pmc)) m.ours = pmc.WorkingSetSize; +#else + uint64_t available = 0; + std::ifstream in("/proc/meminfo"); + for (std::string line; std::getline(in, line);) { + unsigned long long kb = 0; + if (std::sscanf(line.c_str(), "MemTotal: %llu kB", &kb) == 1) m.total = kb * 1024; + else if (std::sscanf(line.c_str(), "MemAvailable: %llu kB", &kb) == 1) available = kb * 1024; + } + m.used = m.total > available ? m.total - available : 0; + long pages = 0, resident = 0; + if (std::FILE* f = std::fopen("/proc/self/statm", "r")) { + if (std::fscanf(f, "%ld %ld", &pages, &resident) != 2) resident = 0; + std::fclose(f); + } + m.ours = uint64_t(resident) * uint64_t(sysconf(_SC_PAGESIZE)); +#endif + return m; +} + +} // namespace keel::sys diff --git a/engine/core/sysinfo.h b/engine/core/sysinfo.h new file mode 100644 index 0000000..a14bae5 --- /dev/null +++ b/engine/core/sysinfo.h @@ -0,0 +1,21 @@ +#pragma once +// What machine this is (the debug overlay's corner, crash reports): the OS, the CPU, memory. +#include +#include + +namespace keel::sys { + +struct Machine { + std::string os; // "NixOS 25.11 (Linux 6.18.54)", "Windows 11 (build 26100)" + std::string cpu; // "AMD Ryzen 7 5700G with Radeon Graphics" + int threads = 0; +}; +const Machine& machine(); // (worked out once) + +struct Memory { + uint64_t total = 0, used = 0; // the whole machine's RAM (used: not available to programs) + uint64_t ours = 0; // this process's resident memory +}; +Memory memory(); + +} // namespace keel::sys diff --git a/engine/core/time.h b/engine/core/time.h new file mode 100644 index 0000000..a5a1417 --- /dev/null +++ b/engine/core/time.h @@ -0,0 +1,13 @@ +#pragma once +#include + +namespace keel { + +// Seconds since program start, monotonic. +inline double now() { + using clock = std::chrono::steady_clock; + static const clock::time_point start = clock::now(); + return std::chrono::duration(clock::now() - start).count(); +} + +} // namespace keel diff --git a/engine/crash.cpp b/engine/crash.cpp new file mode 100644 index 0000000..c82a474 --- /dev/null +++ b/engine/crash.cpp @@ -0,0 +1,394 @@ +#include "crash.h" + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include +#if !defined(__ANDROID__) // (bionic: backtrace() from Android 13 only) +#include +#endif +#include +#include +#include +#endif + +namespace keel::crash { +namespace { +std::string g_report, g_exe; +std::vector g_args; // reporter's command line (exe -crashreport file -- args) +std::vector g_argv; // ...as pointers, made before anything goes wrong +std::atomic g_beat{0.0}; +std::atomic g_busy{0}; +std::atomic g_fired{false}; +#ifndef _WIN32 +pthread_t g_main; +#endif + +double seconds() { return std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); } + +// The report: a headline, then the backtrace and the end of the log. +void writeReport(const char* what) { +#ifdef _WIN32 + FILE* f = std::fopen(g_report.c_str(), "w"); + if (!f) return; + std::fprintf(f, "%s\n\n", what); + const auto& h = log::history(); + std::fprintf(f, "--- last log lines ---\n"); + for (size_t i = h.size() > 80 ? h.size() - 80 : 0; i < h.size(); ++i) std::fprintf(f, "%s\n", h[i].text.c_str()); + std::fclose(f); +#else + const int fd = open(g_report.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0) return; + auto put = [&](const char* s) { (void)!::write(fd, s, std::strlen(s)); }; + put(what); + put("\n\n--- backtrace ---\n"); +#if defined(__ANDROID__) + put("(none on Android: see logcat)\n"); +#else + void* frames[64]; + backtrace_symbols_fd(frames, backtrace(frames, 64), fd); +#endif + put("\n--- last log lines ---\n"); + const auto& h = log::history(); // best effort: we're crashing anyway + for (size_t i = h.size() > 80 ? h.size() - 80 : 0; i < h.size(); ++i) put(h[i].text.c_str()), put("\n"); + close(fd); +#endif +} + +void launchReporter() { +#ifdef _WIN32 + _spawnv(_P_NOWAIT, g_exe.c_str(), g_argv.data()); +#elif defined(__ANDROID__) + // (an app is a library in the Java runtime: no second process to report with; the report + // file and logcat have it) +#else + if (fork() == 0) { + setsid(); // on its own: whatever waited on the app (a terminal, a pipe) isn't kept waiting + if (const int null = open("/dev/null", O_RDWR); null >= 0) dup2(null, 0), dup2(null, 1), dup2(null, 2); + execv(g_exe.c_str(), g_argv.data()); + _exit(127); + } +#endif +} + +[[noreturn]] void fail(const char* what) { + if (!g_fired.exchange(true)) { + writeReport(what); + launchReporter(); + } +#ifdef _WIN32 + TerminateProcess(GetCurrentProcess(), 3); +#endif + _exit(3); +} + +#ifdef _WIN32 +LONG WINAPI onException(EXCEPTION_POINTERS* e) { + char what[128]; + std::snprintf(what, sizeof what, "Fry3DX crashed (exception 0x%08lX).", e->ExceptionRecord->ExceptionCode); + fail(what); +} +#else +void onSignal(int sig) { + const char* what = sig == SIGSEGV ? "Fry3DX crashed: it touched memory it shouldn't have (SIGSEGV)." + : sig == SIGBUS ? "Fry3DX crashed: a bad memory access (SIGBUS)." + : sig == SIGFPE ? "Fry3DX crashed: a maths error, like dividing by zero (SIGFPE)." + : sig == SIGILL ? "Fry3DX crashed: an illegal instruction (SIGILL)." + : sig == SIGABRT ? "Fry3DX crashed: it gave up on an error it couldn't handle (abort)." + : sig == SIGUSR2 ? "Fry3DX froze: no frame for a long time (the watchdog stopped it). Backtrace of the stuck thread:" + : "Fry3DX crashed."; + fail(what); +} +#endif + +bool debuggerAttached() { +#ifdef _WIN32 + return IsDebuggerPresent(); +#else + std::ifstream f("/proc/self/status"); + for (std::string line; std::getline(f, line);) + if (line.rfind("TracerPid:", 0) == 0) return std::atoi(line.c_str() + 10) != 0; + return false; +#endif +} + +void watchdog() { + constexpr double kTimeout = 45.0; + for (;;) { + std::this_thread::sleep_for(std::chrono::seconds(1)); + if (g_busy > 0 || g_beat == 0.0) { + g_beat = seconds(); + continue; + } + if (seconds() - g_beat < kTimeout || debuggerAttached()) continue; +#ifndef _WIN32 + pthread_kill(g_main, SIGUSR2); // its handler reports from the stuck thread (its backtrace) + std::this_thread::sleep_for(std::chrono::seconds(3)); +#endif + fail("Fry3DX froze: no frame for a long time (the watchdog stopped it)."); + } +} +} // namespace + +void install(const std::string& prefPath, const std::vector& args, bool dog) { + g_report = prefPath + "crash.txt"; +#ifdef _WIN32 + char exe[1024] = {}; + GetModuleFileNameA(nullptr, exe, sizeof exe); + g_exe = exe; +#else + g_exe = std::filesystem::read_symlink("/proc/self/exe").string(); + g_main = pthread_self(); +#endif + g_args = {g_exe, "-crashreport", g_report, "--"}; + for (size_t i = 1; i < args.size(); ++i) g_args.push_back(args[i]); +#ifdef _WIN32 + for (std::string& x : g_args) + if (x.find(' ') != std::string::npos) x = "\"" + x + "\""; +#endif + for (std::string& a : g_args) g_argv.push_back(a.data()); + g_argv.push_back(nullptr); +#ifdef _WIN32 + SetUnhandledExceptionFilter(onException); +#else + // On a stack of its own, so a stack overflow can still be reported. + static std::vector altStack(1 << 16); + stack_t ss{}; + ss.ss_sp = altStack.data(), ss.ss_size = altStack.size(); + sigaltstack(&ss, nullptr); + struct sigaction sa{}; + sa.sa_handler = onSignal; + sa.sa_flags = SA_ONSTACK; + for (int sig : {SIGSEGV, SIGBUS, SIGFPE, SIGILL, SIGABRT, SIGUSR2}) sigaction(sig, &sa, nullptr); +#endif + std::set_terminate([] { + std::string what = "Fry3DX crashed: an error nobody caught"; + try { + if (auto e = std::current_exception()) std::rethrow_exception(e); + } catch (const std::exception& e) { + what += std::string(" (") + e.what() + ")"; + } catch (...) { + } + what += "."; + fail(what.c_str()); + }); + if (dog) std::thread(watchdog).detach(); +} + +void heartbeat() { g_beat = seconds(); } +Busy::Busy(const char*) { ++g_busy; } +Busy::~Busy() { + heartbeat(); + --g_busy; +} + +// ── The crash screen: SDL's 2D renderer and its built-in 8×8 font, scaled up ── +int runReporter(int argc, char** argv) { + if (argc < 3 || std::strcmp(argv[1], "-crashreport") != 0) return -1; + std::string report; + { + std::ifstream f(argv[2]); + std::stringstream ss; + ss << f.rdbuf(); + report = ss.str(); + } + std::vector restart; + for (int i = 3; i < argc; ++i) + if (std::strcmp(argv[i], "--") != 0) restart.emplace_back(argv[i]); + std::vector lines; + { + std::stringstream ss(report); + for (std::string l; std::getline(ss, l);) lines.push_back(l); + } + const std::string headline = lines.empty() ? "Fry3DX crashed." : lines[0]; + + if (!SDL_Init(SDL_INIT_VIDEO)) return 1; + SDL_Window* win = nullptr; + SDL_Renderer* ren = nullptr; + if (!SDL_CreateWindowAndRenderer("Fry3DX - something got burnt", 1000, 620, SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY, &win, &ren)) return 1; + SDL_SetRenderVSync(ren, 1); + SDL_SetRenderDrawBlendMode(ren, SDL_BLENDMODE_BLEND); + auto text = [&](float x, float y, float scale, const std::string& s, SDL_Color c) { + SDL_SetRenderScale(ren, scale, scale); + SDL_SetRenderDrawColor(ren, c.r, c.g, c.b, c.a); + SDL_RenderDebugText(ren, x / scale, y / scale, s.c_str()); + SDL_SetRenderScale(ren, 1, 1); + }; + // Shapes for the picture: an ellipse (filled), a box. + auto ellipse = [&](float cx, float cy, float rx, float ry, SDL_FColor c) { + std::vector v; + const int n = 48; + for (int i = 0; i < n; ++i) { + const float a0 = 6.2831853f * i / n, a1 = 6.2831853f * (i + 1) / n; + v.push_back({{cx, cy}, c, {}}); + v.push_back({{cx + std::cos(a0) * rx, cy + std::sin(a0) * ry}, c, {}}); + v.push_back({{cx + std::cos(a1) * rx, cy + std::sin(a1) * ry}, c, {}}); + } + SDL_RenderGeometry(ren, nullptr, v.data(), int(v.size()), nullptr, 0); + }; + auto quad = [&](SDL_FPoint a, SDL_FPoint b, SDL_FPoint c, SDL_FPoint d, SDL_FColor col) { + const SDL_Vertex v[6] = {{a, col, {}}, {b, col, {}}, {c, col, {}}, {a, col, {}}, {c, col, {}}, {d, col, {}}}; + SDL_RenderGeometry(ren, nullptr, v, 6, nullptr, 0); + }; + auto rgb = [](int r, int g, int b, float a = 1.0f) { return SDL_FColor{r / 255.0f, g / 255.0f, b / 255.0f, a}; }; + // Word wrap for the 8-pixel font. + auto wrap = [](const std::string& s, size_t cols) { + std::vector out; + std::string line; + std::stringstream ss(s); + for (std::string w; ss >> w;) { + if (!line.empty() && line.size() + 1 + w.size() > cols) out.push_back(line), line.clear(); + line += (line.empty() ? "" : " ") + w; + } + if (!line.empty()) out.push_back(line); + return out; + }; + const SDL_Color ink{236, 232, 226, 255}, dim{150, 146, 140, 255}, orange{255, 140, 60, 255}; + int scroll = 0, result = 0; + std::string toast; + double toastUntil = 0; + for (bool running = true; running;) { + int w = 0, h = 0; + SDL_GetRenderOutputSize(ren, &w, &h); + const float s = std::min(h / 620.0f, w / 1000.0f); + const double t = SDL_GetTicks() / 1000.0; + struct Button { const char* label; float x; } buttons[] = {{"OPEN IT AGAIN", 60 * s}, {"COPY REPORT", 290 * s}, {"REPORT ON GITHUB", 520 * s}, {"CLOSE", 750 * s}}; + const float by = h - 90 * s, bw = 210 * s, bh = 50 * s; + float mx = 0, my = 0; + SDL_GetMouseState(&mx, &my); + float px = 1, py = 1; // window → pixels + { + int ww, wh; + SDL_GetWindowSize(win, &ww, &wh); + px = float(w) / ww, py = float(h) / wh; + } + mx *= px, my *= py; + int clicked = -1; + for (SDL_Event e; SDL_PollEvent(&e);) { + if (e.type == SDL_EVENT_QUIT) running = false; + if (e.type == SDL_EVENT_KEY_DOWN) { + if (e.key.key == SDLK_ESCAPE || e.key.key == SDLK_Q) running = false; + if (e.key.key == SDLK_R) clicked = 0; + if (e.key.key == SDLK_C) clicked = 1; + } + if (e.type == SDL_EVENT_MOUSE_WHEEL) scroll = std::max(0, scroll - int(e.wheel.y * 3)); + if (e.type == SDL_EVENT_MOUSE_BUTTON_DOWN && e.button.button == SDL_BUTTON_LEFT) + for (int i = 0; i < 4; ++i) + if (mx >= buttons[i].x && mx < buttons[i].x + bw && my >= by && my < by + bh) clicked = i; + } + if (clicked == 0) { // opened again as it was started (it offers the autosave back) + std::vector a; + std::string exe = argv[0]; + a.push_back(exe.data()); + for (std::string& r : restart) a.push_back(r.data()); + a.push_back(nullptr); + SDL_DestroyRenderer(ren); + SDL_DestroyWindow(win); + SDL_Quit(); +#ifdef _WIN32 + _spawnv(_P_NOWAIT, exe.c_str(), a.data()); + return 0; +#else + execv(std::filesystem::read_symlink("/proc/self/exe").c_str(), a.data()); + return 1; +#endif + } + if (clicked == 1 || clicked == 2) { + SDL_SetClipboardText(report.c_str()); + toast = clicked == 1 ? "Copied: paste it into a bug report." : "Copied: paste it into the new issue."; + toastUntil = t + 3.0; + if (clicked == 2) SDL_OpenURL("https://github.com/Nexoniarz/Fry3DX/issues/new"); + } + if (clicked == 3) running = false; + + SDL_SetRenderDrawColor(ren, 22, 22, 25, 255); + SDL_RenderClear(ren); + // The picture, top right: a pan, a cube on it burnt black, smoke going up. + { + const float cx = w - 230 * s, cy = 170 * s; + quad({cx + 80 * s, cy - 6 * s}, {cx + 200 * s, cy - 30 * s}, {cx + 204 * s, cy - 16 * s}, {cx + 84 * s, cy + 8 * s}, rgb(58, 58, 64)); // handle + ellipse(cx, cy + 10 * s, 105 * s, 34 * s, rgb(34, 34, 38)); + ellipse(cx, cy, 105 * s, 34 * s, rgb(70, 70, 78)); + ellipse(cx, cy + 2 * s, 88 * s, 26 * s, rgb(28, 28, 32)); + for (int i = 0; i < 3; ++i) { // flames under it + const float f = float(std::sin(t * 7 + i * 2.1) * 0.5 + 0.5); + const float fx = cx + (i - 1) * 50 * s; + ellipse(fx, cy + 46 * s, (14 + 4 * f) * s, (12 + 6 * f) * s, rgb(255, 74, 28, 0.85f)); + ellipse(fx, cy + 48 * s, 7 * s, (6 + 4 * f) * s, rgb(255, 194, 74, 0.9f)); + } + const float k = 26 * s; // the cube (seen from a corner), charred + quad({cx, cy - 2 * k}, {cx + k, cy - 1.5f * k}, {cx, cy - k}, {cx - k, cy - 1.5f * k}, rgb(64, 52, 46)); + quad({cx - k, cy - 1.5f * k}, {cx, cy - k}, {cx, cy}, {cx - k, cy - 0.5f * k}, rgb(40, 32, 28)); + quad({cx, cy - k}, {cx + k, cy - 1.5f * k}, {cx + k, cy - 0.5f * k}, {cx, cy}, rgb(26, 21, 19)); + for (int i = 0; i < 6; ++i) { // smoke, rising and fading + const double life = std::fmod(t * 0.45 + i / 6.0, 1.0); + const float sx = cx + float(std::sin(life * 6 + i) * 18 * s), sy = cy - 2.2f * k - float(life * 80 * s); + ellipse(sx, sy, float(10 + life * 22) * s, float(8 + life * 16) * s, rgb(150, 150, 158, float(0.35 * (1 - life)))); + } + } + text(60 * s, 60 * s, 4 * s, "Fry3DX", orange); + text(60 * s, 110 * s, 2.5f * s, "Something got burnt.", ink); + float y = 160 * s; + for (const std::string& l : wrap(headline, size_t((w - 400 * s) / (16 * s)))) + text(60 * s, y, 2 * s, l, ink), y += 24 * s; + y += 6 * s; + for (const std::string& l : wrap("Your work isn't lost: Open it again and Fry3DX offers back the last autosave.", size_t((w - 400 * s) / (12 * s)))) + text(60 * s, y, 1.5f * s, l, {130, 210, 120, 255}), y += 18 * s; + text(60 * s, y + 4 * s, 1.25f * s, "Report saved to " + std::string(argv[2]), dim); + y += 30 * s; + // The report's tail, in a panel, scrollable. + const SDL_FRect panel{50 * s, y - 8 * s, w - 100 * s, by - 20 * s - (y - 8 * s)}; + SDL_SetRenderDrawColor(ren, 15, 15, 17, 255); + SDL_RenderFillRect(ren, &panel); + SDL_SetRenderDrawColor(ren, 255, 120, 40, 255); + const SDL_FRect edge{panel.x, panel.y, 3 * s, panel.h}; + SDL_RenderFillRect(ren, &edge); + const int visible = std::max(1, int((panel.h - 10 * s) / (13 * s))); + const int start = std::clamp(int(lines.size()) - visible - scroll, 1, std::max(1, int(lines.size()) - 1)); + for (int i = start; i < int(lines.size()) && y < by - 30 * s; ++i, y += 13 * s) + text(64 * s, y, 1.25f * s, lines[size_t(i)].substr(0, size_t((w - 140 * s) / (10 * s))), {165, 160, 152, 255}); + for (int i = 0; i < 4; ++i) { + const bool hover = mx >= buttons[i].x && mx < buttons[i].x + bw && my >= by && my < by + bh; + SDL_FRect r{buttons[i].x, by, bw, bh}; + if (i == 0 || hover) SDL_SetRenderDrawColor(ren, hover ? 255 : 220, hover ? 140 : 100, hover ? 60 : 30, 255); + else SDL_SetRenderDrawColor(ren, 46, 46, 52, 255); + SDL_RenderFillRect(ren, &r); + const float n = float(std::strlen(buttons[i].label)), ts = std::min(1.75f * s, (bw - 24 * s) / (n * 8)); // (fits its button) + text(buttons[i].x + (bw - n * 8 * ts) / 2, by + bh / 2 - 4 * ts, ts, buttons[i].label, ink); + } + text(60 * s, by + bh + 12 * s, 1.25f * s, "R open again C copy Esc close", dim); + if (t < toastUntil) text(60 * s, by - 16 * s, 1.5f * s, toast, orange); + if (const char* shot = std::getenv("KEEL_CRASHSHOT")) { // for checking the screen: one frame to a file, then quit + if (SDL_Surface* px = SDL_RenderReadPixels(ren, nullptr)) SDL_SaveBMP(px, shot), SDL_DestroySurface(px); + running = false; + } + SDL_RenderPresent(ren); + } + SDL_DestroyRenderer(ren); + SDL_DestroyWindow(win); + SDL_Quit(); + return result; +} + +} // namespace keel::crash diff --git a/engine/crash.h b/engine/crash.h new file mode 100644 index 0000000..b77c5df --- /dev/null +++ b/engine/crash.h @@ -0,0 +1,25 @@ +#pragma once +// Crashes and freezes end on a crash screen instead of a frozen or vanished window: a crash +// (segfault, abort, an uncaught exception) or a freeze (no frame for a while: the watchdog) +// writes a report (what happened, a backtrace, the last log lines) to /crash.txt and +// starts the program again as `-crashreport `, which shows ":(" with what happened and +// Restart / Copy report / Quit. Off under a debugger and with -nowatchdog (the watchdog only). +#include +#include + +namespace keel::crash { + +// After the pref path is known; args: the command line, for Restart. +void install(const std::string& prefPath, const std::vector& args, bool watchdog); +// Once a frame: the main loop is alive. +void heartbeat(); +// While something on the main thread is slow on purpose (building, loading), the watchdog waits. +struct Busy { + explicit Busy(const char* what); + ~Busy(); +}; +// `-crashreport [-- args]`: shows the crash screen and returns the exit code; -1 if the +// command line isn't one. +int runReporter(int argc, char** argv); + +} // namespace keel::crash diff --git a/engine/engine.cpp b/engine/engine.cpp new file mode 100644 index 0000000..5442c38 --- /dev/null +++ b/engine/engine.cpp @@ -0,0 +1,580 @@ +#include "engine.h" +#include "crash.h" +#include "audio/audio.h" +#include "core/console.h" +#include "core/cvar.h" +#include "core/log.h" +#include "core/time.h" +#include "fs/vfs.h" +#include "platform/input.h" +#include "platform/touch.h" +#include "platform/window.h" +#include "render/renderer.h" +#include "render/screenshot.h" +#include "ui/ui.h" + +#include + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +extern "C" __declspec(dllimport) unsigned long __stdcall GetModuleFileNameA(void*, char*, unsigned long); +#else +#include +#endif + +namespace keel { +namespace { +volatile std::sig_atomic_t g_quit = false; +bool g_dedicated = false; +// -test: for automated visual checks while someone uses the machine. The window doesn't +// take focus, ignores mouse and keyboard, plays no sound and never saves the config. +bool g_testMode = false; +std::string g_configPath; + +#ifdef NDEBUG +Cvar r_validation("r_validation", "0", CVAR_ARCHIVE, "Enable Vulkan validation layers (restart needed)"); +#else +Cvar r_validation("r_validation", "1", CVAR_ARCHIVE, "Enable Vulkan validation layers (restart needed)"); +#endif +Cvar vid_mode("vid_mode", "0", CVAR_ARCHIVE, "0 = window, 1 = borderless fullscreen, 2 = fullscreen"); +Cvar vid_width("vid_width", "1600", CVAR_ARCHIVE, "Window / fullscreen width"); +Cvar vid_height("vid_height", "900", CVAR_ARCHIVE, "Window / fullscreen height"); +Cvar r_renderer("r_renderer", "vulkan", CVAR_ARCHIVE, + "Graphics API: vulkan, opengl (4.6), or the cut-down fallbacks for old GPUs: opengl40, opengl31 (restart needed)"); +Cvar hotreload("hotreload", "1", CVAR_ARCHIVE, + "Reload textures, models, sounds, scripts and the map when their files change (with tools/watch.py compiling as you save)"); +Cvar fps_max_menu("fps_max_menu", "120", CVAR_ARCHIVE, "Frame rate limit in menus and while paused (0 = same as fps_max)"); +Cvar fps_max_nofocus("fps_max_nofocus", "30", CVAR_ARCHIVE, "Frame rate limit while another window has focus (10 when minimized; 0 = no limit)"); +Cvar fps_max("fps_max", "0", CVAR_ARCHIVE, "Frame rate limit (0 = none; vsync also limits it)"); +Window* g_window = nullptr; +Cmd cmd_vid_modes("vid_modes", [](const Args&) { + if (!g_window) return; + for (auto [w, h] : g_window->resolutions()) log::info(" {}x{}", w, h); +}, "List the display's fullscreen resolutions"); +Cvar r_speeds("r_speeds", "0", CVAR_NONE, "Show what the renderer draws each frame (draws, culled, triangles, shadows)"); +Cvar cl_showfps("cl_showfps", "0", CVAR_ARCHIVE, "Show frame rate in the corner"); + +Cmd cmd_quit("quit", [](const Args&) { g_quit = true; }, "Quit"); +Cmd cmd_exit("exit", [](const Args&) { g_quit = true; }, "Quit"); +Renderer* g_renderer = nullptr; +std::string g_prefPath; +Cmd cmd_version("version", [](const Args&) { + log::info("Fry3DX, build {} from {}, {} renderer", KEEL_COMMIT, KEEL_COMMIT_DATE, g_renderer ? g_renderer->apiName() : "no"); +}, "Engine version and build date"); +Cmd cmd_screenshot("screenshot", [](const Args& a) { + if (!g_renderer) return; + std::string name = a.size() > 1 ? a[1] : ""; + if (name.empty()) { + char stamp[32]; + const std::time_t now = std::time(nullptr); + std::strftime(stamp, sizeof stamp, "%Y%m%d_%H%M%S", std::localtime(&now)); + name = std::string("shot_") + stamp + ".png"; + } + if (!name.ends_with(".png")) name += ".png"; + g_renderer->requestScreenshot(std::filesystem::path(name).is_absolute() ? name : g_prefPath + "screenshots/" + name); +}, "Save the screen as a PNG: screenshot [name or absolute path]"); +Cmd cmd_exec("exec", [](const Args& a) { + if (a.size() < 2) return log::info("usage: exec "); + // Game files first (cfg/autoexec.cfg), then a plain path on disk. + std::optional text = fs::readText(a[1]); + if (!text) text = fs::readText("cfg/" + a[1]); + if (!text) { + std::ifstream in(a[1]); + if (!in) return log::warn("Couldn't exec {}", a[1]); + std::stringstream ss; + ss << in.rdbuf(); + text = ss.str(); + } + execute(*text); +}, "Run commands from a file"); + +void saveConfig() { + if (g_testMode) return; // (automated runs mustn't change the player's settings) + std::ofstream out(g_configPath); + out << "// Written on exit - edit autoexec.cfg instead.\n"; + if (!g_dedicated) out << input::bindingsConfig(); + for (Cvar* c : Cvar::all()) + if ((c->flags() & CVAR_ARCHIVE) && c->str() != c->defaultValue()) + out << c->name() << " \"" << c->str() << "\"\n"; +} + +// Typing, for automated tests of text boxes: real key and text events through the event loop. +// sdl_type "hello" or keys in braces: {enter} {esc} {y}. +int g_typedEvents = 0; // sdl_type's, let through in -test + +Cmd cmd_sdl_type("sdl_type", [](const Args& a) { + static std::vector texts; // (SDL keeps a pointer to the text until it's handled) + std::string s; + for (size_t i = 1; i < a.size(); ++i) s += (i > 1 ? " " : "") + a[i]; + auto key = [](SDL_Scancode sc, bool down) { + SDL_Event e{}; + e.type = down ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP; + e.key.scancode = sc; + e.key.key = SDL_GetKeyFromScancode(sc, SDL_KMOD_NONE, false); + e.key.down = down; + e.key.windowID = g_window ? SDL_GetWindowID(g_window->handle()) : 0; + if (SDL_PushEvent(&e)) ++g_typedEvents; + }; + { // (tests run unfocused, and text boxes ignore an unfocused window) + SDL_Event f{}; + f.type = SDL_EVENT_WINDOW_FOCUS_GAINED; + f.window.windowID = g_window ? SDL_GetWindowID(g_window->handle()) : 0; + if (SDL_PushEvent(&f)) ++g_typedEvents; + } + for (size_t i = 0; i < s.size(); ++i) { + if (s[i] == '{') { + const size_t end = s.find('}', i); + const std::string name = s.substr(i + 1, end - i - 1); + const SDL_Scancode sc = name == "enter" ? SDL_SCANCODE_RETURN : name == "esc" ? SDL_SCANCODE_ESCAPE : SDL_GetScancodeFromName(name.c_str()); + key(sc, true), key(sc, false); + i = end; + continue; + } + const SDL_Scancode sc = SDL_GetScancodeFromKey(SDL_Keycode(std::tolower(static_cast(s[i]))), nullptr); + key(sc, true); + texts.push_back(std::string(1, s[i])); + SDL_Event t{}; + t.type = SDL_EVENT_TEXT_INPUT; + t.text.text = texts.back().c_str(); + t.text.windowID = g_window ? SDL_GetWindowID(g_window->handle()) : 0; + if (SDL_PushEvent(&t)) ++g_typedEvents; + key(sc, false); + } +}, "(tests) Type text in as key presses: sdl_type \"hi{enter}\""); + +Cmd cmd_writeconfig("host_writeconfig", [](const Args&) { + saveConfig(); + log::info("Wrote {}", g_configPath); +}, "Save settings and key bindings now (they're also saved on exit)"); + +// "+map foo +name bob" -> {"map foo", "name \"bob\""} +std::vector commandLine(int argc, char** argv) { + std::vector out; + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg.starts_with('+')) out.push_back(arg.substr(1)); + else if (arg == "-dedicated") g_dedicated = true; + else if (arg == "-test") g_testMode = true; + else if (arg == "-dev") out.push_back("developer 1"); + else if (!out.empty()) out.back() += " \"" + arg + "\""; + } + return out; +} + +// Like Source: cvar settings on the command line apply before any commands, so +// "+map test +name bob" joins as bob. Pass 0 = settings, pass 1 = commands. +// Commands run as one line so "+map test +wait 60 +screenshot" chains through wait. +void executeCommandLine(const std::vector& lines, int pass) { + std::string commands; + for (const std::string& line : lines) { + const Args args = tokenize(line); + const bool isSetting = !args.empty() && (Cvar::find(args[0]) || args[0] == "set"); + if (isSetting != (pass == 0)) continue; + if (pass == 0) execute(line); + else commands += line + ";"; + } + if (!commands.empty()) execute(commands); +} + +Cvar fs_mods("fs_mods", "", CVAR_ARCHIVE, "Mods to load from the mods folder: \"all\" or \"name1,name2\" (restart needed)"); + +Cvar r_profile("r_profile", "0", CVAR_ARCHIVE, "Where each frame's time goes: the app, the CPU's rendering work, each GPU pass (ms)"); +float g_gameMs = 0, g_drawMs = 0; + +// r_profile 1: a bar per part of the frame (like Source's VProf / showbudget). +void drawProfile() { + if (!r_profile.b() || !g_renderer) return; + const Renderer::Timings& t = g_renderer->timings(); + static float smooth[32] = {}; + std::vector> rows = {{"game (logic, network, sound)", g_gameMs}, {"game draws (UI, scene list)", g_drawMs}, + {"render: build draw list", t.build}, {"render: record + submit", t.record}, + {"waiting for the GPU", t.wait}}; + float gpu = 0; + for (const auto& [name, ms] : t.gpu) rows.push_back({std::string("gpu: ") + name, ms}), gpu += ms; + rows.push_back({"gpu total", gpu}); + ImGui::SetNextWindowPos(ImVec2(10, ImGui::GetMainViewport()->WorkSize.y * 0.3f), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.65f); + ImGui::Begin("r_profile", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_AlwaysAutoResize); + ImGui::Text("%.2f ms %.0f fps", 1000.0f / ImGui::GetIO().Framerate, ImGui::GetIO().Framerate); + ImDrawList* dl = ImGui::GetWindowDrawList(); + for (size_t i = 0; i < rows.size() && i < 32; ++i) { + smooth[i] += (rows[i].second - smooth[i]) * 0.1f; // steady numbers + const float ms = smooth[i]; + const ImVec2 p = ImGui::GetCursorScreenPos(); + const float w = std::min(ms / 16.67f, 1.0f) * 200.0f; // the bar: a 60 fps frame is full width + const ImU32 col = rows[i].first.starts_with("gpu") ? IM_COL32(90, 170, 255, 200) : IM_COL32(255, 180, 70, 200); + dl->AddRectFilled(ImVec2(p.x, p.y + 2), ImVec2(p.x + w, p.y + 14), col); + ImGui::Dummy(ImVec2(205, 16)); + ImGui::SameLine(); + ImGui::Text("%6.2f %s", ms, rows[i].first.c_str()); + } + ImGui::End(); +} + +void drawFps() { + drawProfile(); + if (r_speeds.b() && g_renderer) { // Quake's r_speeds: what the renderer did last frame + const Renderer::DrawStats& d = g_renderer->drawStats(); + const Renderer::LightStats& l = g_renderer->lightStats(); + ImGui::SetNextWindowPos(ImVec2(ImGui::GetMainViewport()->WorkSize.x - 10, 60), 0, ImVec2(1, 0)); + ImGui::SetNextWindowBgAlpha(0.5f); + ImGui::Begin("r_speeds", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_AlwaysAutoResize); + ImGui::Text("%.2f ms %.0f fps", 1000.0f / ImGui::GetIO().Framerate, ImGui::GetIO().Framerate); + ImGui::Text("draws %u (+%u translucent), culled %u (%u hidden behind things)", d.drawn, d.translucent, d.culled, d.occluded); + ImGui::Text("triangles %u", d.triangles); + ImGui::Text("sun shadow draws %u, light shadow draws %u", d.sunShadow, d.lightShadow); + ImGui::Text("lights %u (%u shadowed)", l.lights, l.shadowed); + ImGui::End(); + } + if (!cl_showfps.b()) return; + ImGui::SetNextWindowPos(ImVec2(ImGui::GetMainViewport()->WorkSize.x - 10, 10), 0, ImVec2(1, 0)); + ImGui::SetNextWindowBgAlpha(0.4f); + ImGui::Begin("fps", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_AlwaysAutoResize); + const float fps = ImGui::GetIO().Framerate; + ImGui::Text("%.0f fps %.2f ms", fps, 1000.0f / fps); + ImGui::End(); +} + +int runDedicated(Game& game) { + // No SDL here, so handle Ctrl+C / kill ourselves to shut down cleanly. + std::signal(SIGINT, [](int) { g_quit = true; }); + std::signal(SIGTERM, [](int) { g_quit = true; }); + double last = now(); + while (!g_quit) { + crash::heartbeat(); + const double t = now(); + game.frame(t - last); + executeDeferred(); + last = t; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + return 0; +} + +int runClient(Game& game) { + Window window; + Renderer renderer; + g_renderer = &renderer; + if (g_testMode) { + SDL_SetHint(SDL_HINT_WINDOW_ACTIVATE_WHEN_SHOWN, "0"); + input::disableCapture(); + } + g_window = &window; + const int w = std::max(vid_width.i(), 640), h = std::max(vid_height.i(), 480); + // The API asked for, then the others: the modern pair, then the fallbacks for old GPUs. + const std::string want = r_renderer.str(); + const RenderApi first = want == "opengl" ? RenderApi::OpenGL : want == "opengl40" ? RenderApi::OpenGL40 : want == "opengl31" ? RenderApi::OpenGL31 : RenderApi::Vulkan; + std::vector order{first}; + for (RenderApi a : {RenderApi::Vulkan, RenderApi::OpenGL, RenderApi::OpenGL40, RenderApi::OpenGL31}) + if (std::find(order.begin(), order.end(), a) == order.end() && (first != RenderApi::OpenGL31 || a == RenderApi::OpenGL31)) order.push_back(a); + bool ok = false; + for (RenderApi api : order) { + ok = window.init(game.title(), w, h, api != RenderApi::Vulkan) && renderer.init(window, r_validation.b(), api); + if (ok) { + if (api != first) + ui::popup("Renderer changed", std::string("Your graphics driver couldn't start the ") + (want.empty() ? "vulkan" : want) + + " renderer, so it's using " + renderer.apiName() + " instead."); + break; + } + log::warn("Renderer {} unavailable, trying the next", int(api)); + renderer.shutdown(); + window.shutdown(); + } + if (!ok) { + renderer.shutdown(); + window.shutdown(); + return 1; + } + if (game.dockingUi()) ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_DockingEnable; + if (auto png = fs::read("branding/icon.png")) { // the window/taskbar icon + int w = 0, h = 0, n = 0; + if (stbi_uc* px = stbi_load_from_memory(png->data(), int(png->size()), &w, &h, &n, 4)) { + window.setIcon(px, w, h); + stbi_image_free(px); + } + } else if (auto data = fs::read("branding/keel_emblem.yapf")) + if (yapf_texture_t* tex = yapf_tex_load_memory(data->data(), data->size(), 0, 1, nullptr)) { + std::vector rgba(size_t(tex->width) * tex->height * 4); + if (yapf_tex_to_rgba8(tex, yapf_tex_surface(tex, 0, 0, 0, 0), 0, rgba.data()) == YAPF_OK) + window.setIcon(rgba.data(), int(tex->width), int(tex->height)); + yapf_tex_free(tex); + } + if (!g_testMode) audio::init(); + ui::init(); + log::info("Press ` (tilde key) to open the console"); + + double last = now(); + while (!g_quit) { + crash::heartbeat(); + SDL_Event ev; + while (SDL_PollEvent(&ev)) { + const bool typed = g_typedEvents > 0 && (ev.type == SDL_EVENT_KEY_DOWN || ev.type == SDL_EVENT_KEY_UP || ev.type == SDL_EVENT_TEXT_INPUT || + ev.type == SDL_EVENT_WINDOW_FOCUS_GAINED); + if (typed) --g_typedEvents; + if (g_testMode && !typed) { // nobody's playing: only window events (and sdl_type's) + if (ev.type == SDL_EVENT_QUIT) g_quit = true; + if (ev.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) renderer.onResize(); + continue; + } + const bool keyDown = ev.type == SDL_EVENT_KEY_DOWN && !ev.key.repeat; + if (keyDown && ev.key.scancode == SDL_SCANCODE_GRAVE) { + console::toggle(); + continue; + } + touch::handleEvent(ev, input::captured()); + // Escape, or a phone's back button. + if (keyDown && (ev.key.scancode == SDL_SCANCODE_ESCAPE || ev.key.scancode == SDL_SCANCODE_AC_BACK)) { + if (console::isOpen()) console::toggle(); + else if (ui::popupOpen()) ui::closePopup(); + else game.onEscape(); + continue; + } + // Don't let the toggle key's character land in the console input. + if (ev.type == SDL_EVENT_TEXT_INPUT && ev.text.text[0] == '`') continue; + input::handleEvent(ev); + if (!input::captured()) ImGui_ImplSDL3_ProcessEvent(&ev); + if (ev.type == SDL_EVENT_QUIT && game.allowQuit()) g_quit = true; + if (ev.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) renderer.onResize(); + } + const bool focused = SDL_GetWindowFlags(window.handle()) & SDL_WINDOW_INPUT_FOCUS; + audio::setFocused(focused); + if (vid_mode.consumeModified() | vid_width.consumeModified() | vid_height.consumeModified()) + window.setMode(vid_mode.i(), std::max(vid_width.i(), 640), std::max(vid_height.i(), 480)); + // Menus need no more than fps_max_menu (like Source's): high frame rates there only heat + // the GPU and make coil whine (often heard in headphones on the onboard jack). + int limit = fps_max.i(); + if (game.wantsCursor() && fps_max_menu.i() > 0 && !g_testMode) limit = limit > 0 ? std::min(limit, fps_max_menu.i()) : fps_max_menu.i(); + // In the background (like Source's engine_no_focus_sleep): another window has the + // keyboard, or it's minimized; no need to draw flat out (and take the GPU from the game + // you're looking at). + if (!focused && !g_testMode && fps_max_nofocus.i() > 0) { + const bool minimized = SDL_GetWindowFlags(window.handle()) & SDL_WINDOW_MINIMIZED; + const int cap = minimized ? 10 : fps_max_nofocus.i(); + limit = limit > 0 ? std::min(limit, cap) : cap; + } + if (limit > 0) { // sleep off the rest of the frame + static double nextFrame = 0; + const double frame = 1.0 / std::max(limit, 10); + nextFrame = std::max(nextFrame + frame, now()); + while (now() < nextFrame - 0.0015) std::this_thread::sleep_for(std::chrono::milliseconds(1)); + while (now() < nextFrame) {} + } + const bool capture = focused && !console::isOpen() && !game.wantsCursor() && !ui::popupOpen(); + // Handing the keyboard between the game and the menus: the menus forget what was held. + // (Keys let go while the game had them never reach the menus: Enter that sent a chat line + // stayed "held" and repeated, closing the chat the moment it opened again.) + if (capture != input::captured()) ImGui::GetIO().ClearInputKeys(); + input::setCaptured(capture); + if (touch::takeEscape()) { // the touch controls' pause button + if (console::isOpen()) console::toggle(); + else if (ui::popupOpen()) ui::closePopup(); + else game.onEscape(); + } + + const double t = now(); + game.frame(t - last); + g_gameMs = float((now() - t) * 1000.0); + last = t; + + // Hot reload (Source 2's resource system, in spirit): what changed on disk comes back in. + static double nextCheck = 0.0; + if (hotreload.i() >= (g_testMode ? 2 : 1) && now() >= nextCheck) { // (tests: hotreload 2) + nextCheck = now() + 0.5; + std::vector changed = renderer.reloadChanged(); + for (std::string& s : audio::reloadChanged()) changed.push_back(std::move(s)); + for (const std::string& s : changed) log::info("Reloaded {}", s); + game.hotReload(changed); + } + renderer.beginUi(); + int winW = 0, winH = 0; + SDL_GetWindowSizeInPixels(window.handle(), &winW, &winH); + ui::beginFrame(float(winH)); + const double drawStart = now(); + game.render(renderer); + g_drawMs = float((now() - drawStart) * 1000.0); + touch::draw(input::captured()); + ui::drawPopups(); + console::draw(); + drawFps(); + renderer.drawFrame(); + executeDeferred(); + } + input::setCaptured(false); + audio::shutdown(); + waitForScreenshots(); + renderer.shutdown(); + window.shutdown(); + return 0; +} +} // namespace + +bool isDedicated() { return g_dedicated; } +bool isTestMode() { return g_testMode; } +const char* rendererName() { return g_renderer ? g_renderer->apiName() : "none"; } +const char* buildVersion() { return KEEL_COMMIT ", " KEEL_COMMIT_DATE; } + +// Replaces this process with a fresh one (Windows: starts it, then this one exits). +void relaunch(const std::vector& args) { + std::vector a = args; +#ifdef _WIN32 + char exe[1024] = {}; + GetModuleFileNameA(nullptr, exe, sizeof exe); + for (std::string& x : a) + if (x.find(' ') != std::string::npos) x = "\"" + x + "\""; +#else + const std::string exe = std::filesystem::read_symlink("/proc/self/exe").string(); +#endif + std::vector argv; + for (std::string& x : a) argv.push_back(x.data()); + argv.push_back(nullptr); + log::info("Restarting"); +#ifdef _WIN32 + _spawnv(_P_NOWAIT, exe, argv.data()); +#else + execv(exe.c_str(), argv.data()); + log::error("Restart failed"); +#endif +} + +std::string g_modsDir; +std::vector g_argv; +bool g_restart = false; +const std::string& modsDirectory() { return g_modsDir; } +void applyMods() { + fs::unmountMods(); + fs::mountMods(g_modsDir, fs_mods.str()); +} +void restart() { + g_restart = true; + execute("quit"); +} +std::vector> displayResolutions() { return g_window ? g_window->resolutions() : std::vector>{}; } + +// Tries the crash screen: "crash" crashes, "crash freeze" hangs (the watchdog steps in), "crash throw" throws. +Cmd cmd_crash("crash", [](const Args& a) { + const std::string how = a.size() > 1 ? a[1] : ""; + if (how == "freeze") for (volatile bool stuck = true; stuck;) {} + if (how == "throw") throw std::runtime_error("crash throw (from the console)"); + volatile int* p = nullptr; + *p = 1; +}, "crash [freeze|throw]: crash on purpose, to see the crash screen"); + +#if defined(__ANDROID__) +// On a phone the content ships inside the APK (assets/, listed in content.txt with a version on +// its first line). It's copied out to the app's own storage the first time, and after an +// update, where it's read like any game folder. Returns that folder. +std::string unpackAndroidContent() { + const char* internal = SDL_GetAndroidInternalStoragePath(); + const std::string dir = std::string(internal ? internal : ".") + "/"; + size_t size = 0; + void* list = SDL_LoadFile("content.txt", &size); // (relative paths read from the APK's assets) + if (!list) { + log::error("No content list in the APK"); + return dir; + } + std::istringstream in(std::string(static_cast(list), size)); + SDL_free(list); + std::string version, have; + std::getline(in, version); + if (std::ifstream v(dir + "content.version"); v) std::getline(v, have); + if (have == version) return dir; + log::info("Unpacking the content ({})", version); + for (std::string rel; std::getline(in, rel);) { + if (rel.empty()) continue; + size_t n = 0; + void* data = SDL_LoadFile(rel.c_str(), &n); + if (!data) { + log::warn("Not in the APK: {}", rel); + continue; + } + std::error_code ec; + std::filesystem::create_directories(std::filesystem::path(dir + rel).parent_path(), ec); + std::ofstream(dir + rel, std::ios::binary).write(static_cast(data), std::streamsize(n)); + SDL_free(data); + } + std::ofstream(dir + "content.version") << version << "\n"; + return dir; +} +#endif + +int run(Game& game, int argc, char** argv) { + if (const int r = crash::runReporter(argc, argv); r >= 0) return r; // the crash screen + log::info("{}", game.title()); + + const std::vector startup = commandLine(argc, argv); // also detects -dedicated + char* pref = SDL_GetPrefPath("Nexoniarz", game.name()); + g_prefPath = pref ? pref : ""; + g_configPath = g_prefPath + (g_dedicated ? "server.cfg" : "config.cfg"); + if (!g_testMode) console::setHistoryFile(g_prefPath + "console_history.txt"); + cmd_exec.complete([](std::string_view p, size_t) { + std::vector v; + for (const std::string& f : fs::list("cfg", ".cfg")) v.push_back(f.substr(4)); // "cfg/x.cfg" → "x.cfg" + return matching(v, p); + }); + SDL_free(pref); + { + std::vector args(argv, argv + argc); + bool dog = !g_testMode; + for (const std::string& a : args) dog = a == "-watchdog" ? true : a == "-nowatchdog" ? false : dog; + crash::install(g_prefPath, args, dog); + } + + // Content: the engine's own (/keel/), then the game's (//) over it, + // each with its packs — like Source's hl2/ under a mod. +#if defined(__ANDROID__) + const std::string exeDir = unpackAndroidContent(); + if (const char* cache = SDL_GetAndroidCachePath()) setenv("TMPDIR", cache, 1); // temp files (script modules) go there + const bool firstRun = !std::filesystem::exists(g_configPath); +#else + const char* base = SDL_GetBasePath(); + const std::string exeDir = base ? base : "./"; +#endif + if (!fs::mountGameDir(exeDir + "keel")) log::warn("Engine folder {}keel not found; fonts and UI won't load", exeDir); + const std::string gameDir = exeDir + game.name(); + if (!fs::mountGameDir(gameDir)) log::warn("Game folder {} not found; no content will load", gameDir); + + game.init(); + // Order matters: saved config first, then the user's autoexec, then the command line. + // Like Source's config_default.cfg: default binds/settings, then the player's own. + if (!g_dedicated && fs::exists("cfg/default.cfg")) execute("exec default.cfg"); + if (std::filesystem::exists(g_configPath)) execute("exec \"" + g_configPath + "\""); +#if defined(__ANDROID__) + if (firstRun) execute("preset Low"); // a phone: start light (the options change it) +#endif + if (fs::exists("cfg/autoexec.cfg")) execute("exec autoexec.cfg"); + executeCommandLine(startup, 0); + g_modsDir = exeDir + "mods/" + game.name(); + for (int i = 0; i < argc; ++i) g_argv.emplace_back(argv[i]); + fs::mountMods(g_modsDir, fs_mods.str()); + executeCommandLine(startup, 1); + + const int result = g_dedicated ? runDedicated(game) : runClient(game); + game.shutdown(); + if (!g_testMode) saveConfig(); + if (g_restart) relaunch(g_argv); + return result; +} + +const std::string& prefPath() { return g_prefPath; } + +} // namespace keel diff --git a/engine/engine.h b/engine/engine.h new file mode 100644 index 0000000..05bd41a --- /dev/null +++ b/engine/engine.h @@ -0,0 +1,62 @@ +#pragma once +#include +#include +#include + +namespace keel { +// Where this game keeps its config and saves (SDL_GetPrefPath), "" before it starts. +const std::string& prefPath(); + + +class Renderer; + +// What a game plugs into the engine. Like Source, the game owns its server and +// client code; the engine owns the window, renderer, console and main loop. +class Game { +public: + virtual ~Game() = default; + virtual const char* name() const = 0; // folder for config/saves, e.g. "fry3dx" + virtual const char* title() const = 0; // window title + + virtual void init() {} + virtual void shutdown() {} + // Every frame, before rendering. dt in seconds. + virtual void frame(double dt) = 0; + // Queue draws and UI. Not called on a dedicated server. + virtual void render(Renderer&) {} + // True while the game shows a menu: the mouse is released and binds don't run. + virtual bool wantsCursor() const { return false; } + // Dear ImGui docking (windows that dock into each other and the screen): tools like this one. + virtual bool dockingUi() const { return false; } + // Escape was pressed (the console has its own key). + virtual void onEscape() {} + // The window's close button (or the OS asking it to close): false keeps it open (to ask + // about saving first; the game quits later itself). The quit command always quits. + virtual bool allowQuit() { return true; } + // Hot reload: called twice a second (hotreload 1) after the engine reloaded what changed + // (`changed`: textures, models, sounds); the game reloads its own (scripts, the map). + virtual void hotReload(const std::vector& changed) { (void)changed; } +}; + +// Runs until quit. Command line works like Quake/Source: +// fry3dx file.fry +set r_vsync 0 +int run(Game& game, int argc, char** argv); + +bool isDedicated(); +// Started with -test (automated runs: no input, no audio, no splash). +bool isTestMode(); +// Fullscreen resolutions of the window's display, largest first (empty when dedicated). +std::vector> displayResolutions(); +// The graphics API in use ("Vulkan 1.3", "OpenGL 4.6"). +const char* rendererName(); +// This build: its git commit and date ("1a2b3c4, 2026-10-11 02:14"). +const char* buildVersion(); +// The game's mods folder (/mods//). +const std::string& modsDirectory(); +// Takes the mods down and mounts the ones fs_mods names now (scripts and new files apply from +// the next map; textures/models already loaded keep their old look until a restart). +void applyMods(); +// Starts the game again with the same command line (after the config is saved). +void restart(); + +} // namespace keel diff --git a/engine/fs/vfs.cpp b/engine/fs/vfs.cpp new file mode 100644 index 0000000..08a1c01 --- /dev/null +++ b/engine/fs/vfs.cpp @@ -0,0 +1,212 @@ +#include "fs/vfs.h" +#include "core/cvar.h" +#include "core/log.h" +#include "fs/ymp.h" + +#include +#include +#include +#include +#include + +namespace keel::fs { +namespace { +namespace stdfs = std::filesystem; + +struct Mount { + std::string root; // directory, or pack file + std::unique_ptr pack; + bool mod = false; +}; +bool g_mountingMods = false; +std::vector g_mounts; // later = higher priority + +std::string normalize(std::string_view p) { + std::string s(p); + std::replace(s.begin(), s.end(), '\\', '/'); + while (s.starts_with("./")) s.erase(0, 2); + while (s.starts_with('/')) s.erase(0, 1); + return s; +} + +bool safe(const std::string& p) { return p.find("..") == std::string::npos; } + +Cmd cmd_path("path", [](const Args&) { + for (auto it = g_mounts.rbegin(); it != g_mounts.rend(); ++it) + log::info(" {}{}", it->root, it->pack ? " (pack)" : ""); +}, "Show mounted directories and packs, highest priority first"); +} // namespace + +bool mountDir(const std::string& dir) { + if (!stdfs::is_directory(dir)) return false; + g_mounts.push_back({stdfs::path(dir).lexically_normal().generic_string(), nullptr, g_mountingMods}); + return true; +} + +bool mountPack(const std::string& file) { + auto pack = std::make_unique(); + if (!pack->open(file)) { + log::warn("Couldn't open pack {}", file); + return false; + } + g_mounts.push_back({file, std::move(pack), g_mountingMods}); + return true; +} + +bool mountGameDir(const std::string& dir) { + if (!stdfs::is_directory(dir)) return false; + std::vector packs; + for (auto& e : stdfs::directory_iterator(dir)) + if (e.is_regular_file() && e.path().extension() == ".ymp") packs.push_back(e.path().generic_string()); + std::sort(packs.begin(), packs.end()); + for (auto& p : packs) mountPack(p); + return mountDir(dir); +} + +void mountMods(const std::string& modsDir, const std::string& which) { + if (which.empty() || !stdfs::is_directory(modsDir)) return; + std::vector entries; + for (auto& e : stdfs::directory_iterator(modsDir)) entries.push_back(e.path()); + std::sort(entries.begin(), entries.end()); + g_mountingMods = true; + for (const auto& p : entries) { + const std::string name = p.stem().string(); + if (!modEnabled(which, name)) continue; + const bool ok = stdfs::is_directory(p) ? mountGameDir(p.string()) : p.extension() == ".ymp" && mountPack(p.string()); + if (ok) log::info("Mod: {}", name); + } + g_mountingMods = false; +} + +void unmountMods() { + std::erase_if(g_mounts, [](const Mount& m) { return m.mod; }); +} + +bool modEnabled(const std::string& which, const std::string& name) { + return which == "all" || ("," + which + ",").find("," + name + ",") != std::string::npos; +} + +namespace { +// mod.txt: `key "value"` (or `key value`) lines; // comments. +void parseModTxt(const std::string& text, ModInfo& m) { + size_t start = 0; + while (start < text.size()) { + size_t end = text.find('\n', start); + if (end == std::string::npos) end = text.size(); + std::string line = text.substr(start, end - start); + start = end + 1; + if (!line.empty() && line.back() == '\r') line.pop_back(); + const size_t k0 = line.find_first_not_of(" \t"); + if (k0 == std::string::npos || line.compare(k0, 2, "//") == 0) continue; + const size_t k1 = line.find_first_of(" \t", k0); + if (k1 == std::string::npos) continue; + const std::string key = line.substr(k0, k1 - k0); + std::string value = line.substr(line.find_first_not_of(" \t", k1)); + if (value.size() >= 2 && value.front() == '"') value = value.substr(1, value.rfind('"') - 1); + if (key == "title") m.title = value; + else if (key == "author") m.author = value; + else if (key == "version") m.version = value; + else if (key == "description") m.description = value; + } +} +bool isAsset(const std::string& f) { return f.starts_with("textures/") || f.starts_with("models/") || f.starts_with("materials/"); } +} // namespace + +std::vector listMods(const std::string& modsDir) { + std::vector out; + std::error_code ec; + if (!stdfs::is_directory(modsDir, ec)) return out; + for (auto& e : stdfs::directory_iterator(modsDir, ec)) { + ModInfo m; + m.name = e.path().stem().string(); + m.path = e.path().generic_string(); + if (e.is_directory()) { + for (auto& f : stdfs::recursive_directory_iterator(e.path(), ec)) { + if (!f.is_regular_file()) continue; + ++m.files; + if (isAsset(stdfs::relative(f.path(), e.path()).generic_string())) m.replacesAssets = true; + } + if (std::ifstream in(e.path() / "mod.txt"); in) parseModTxt(std::string((std::istreambuf_iterator(in)), {}), m); + } else if (e.path().extension() == ".ymp") { + m.pack = true; + YmpArchive pack; + if (!pack.open(m.path)) continue; + for (const std::string& f : pack.files()) { + ++m.files; + if (isAsset(f)) m.replacesAssets = true; + } + if (std::vector txt; pack.read("mod.txt", txt)) parseModTxt(std::string(txt.begin(), txt.end()), m); + } else { + continue; + } + if (m.title.empty()) m.title = m.name; + out.push_back(std::move(m)); + } + std::sort(out.begin(), out.end(), [](const ModInfo& a, const ModInfo& b) { return a.title < b.title; }); + return out; +} + +void unmountAll() { g_mounts.clear(); } + +std::optional> read(std::string_view path) { + const std::string p = normalize(path); + if (!safe(p)) return std::nullopt; + std::vector out; + for (auto it = g_mounts.rbegin(); it != g_mounts.rend(); ++it) { + if (it->pack) { + if (it->pack->read(p, out)) return out; + continue; + } + std::ifstream in(it->root + "/" + p, std::ios::binary); + if (in) return std::vector((std::istreambuf_iterator(in)), {}); + } + return std::nullopt; +} + +std::optional readText(std::string_view path) { + auto data = read(path); + if (!data) return std::nullopt; + return std::string(data->begin(), data->end()); +} + +std::optional mtime(std::string_view path) { + const std::string p = normalize(path); + if (!safe(p)) return std::nullopt; + for (auto it = g_mounts.rbegin(); it != g_mounts.rend(); ++it) { + if (it->pack) { + if (it->pack->contains(p)) return std::nullopt; + continue; + } + std::error_code ec; + const auto t = stdfs::last_write_time(it->root + "/" + p, ec); + if (!ec) return int64_t(t.time_since_epoch().count()); + } + return std::nullopt; +} + +bool exists(std::string_view path) { + const std::string p = normalize(path); + if (!safe(p)) return false; + for (auto& m : g_mounts) + if (m.pack ? m.pack->contains(p) : stdfs::is_regular_file(m.root + "/" + p)) return true; + return false; +} + +std::vector list(std::string_view dir, std::string_view ext) { + const std::string d = normalize(dir); + std::set found; + auto consider = [&](const std::string& f) { + if (f.starts_with(d) && (ext.empty() || f.ends_with(ext))) found.insert(f); + }; + for (auto& m : g_mounts) { + if (m.pack) { + for (auto& f : m.pack->files()) consider(f); + } else if (stdfs::is_directory(m.root + "/" + d)) { + for (auto& e : stdfs::recursive_directory_iterator(m.root + "/" + d)) + if (e.is_regular_file()) consider(stdfs::relative(e.path(), m.root).generic_string()); + } + } + return {found.begin(), found.end()}; +} + +} // namespace keel::fs diff --git a/engine/fs/vfs.h b/engine/fs/vfs.h new file mode 100644 index 0000000..4d50829 --- /dev/null +++ b/engine/fs/vfs.h @@ -0,0 +1,52 @@ +#pragma once +// Virtual filesystem: everything the game loads goes through here. Directories +// and YMP packs are mounted in order; a file in a later mount overrides the +// same path in earlier ones (like Source's search paths), which is how patches +// and mods replace content without touching the base game. +#include +#include +#include +#include +#include + +namespace keel::fs { + +bool mountDir(const std::string& dir); +bool mountPack(const std::string& file); +// Mounts dir/*.ymp (in name order: pak0, pak1, ...) and then dir itself, so +// loose files beat packed ones during development. +bool mountGameDir(const std::string& dir); +// Mods: each folder or .ymp in `modsDir` named in `which` ("all" or "a,b,c"), +// mounted after the game so they override it. +void mountMods(const std::string& modsDir, const std::string& which); +// Takes the mods down again (the engine and game stay), e.g. before mounting a new selection. +void unmountMods(); +void unmountAll(); + +// A mod in the mods folder: a folder or a .ymp, described by an optional mod.txt inside it: +// title "Low gravity" +// author "Someone" +// version "1.0" +// description "Moon gravity and higher jumps." +struct ModInfo { + std::string name; // folder / pack name: what fs_mods lists + std::string path; + bool pack = false; + std::string title, author, version, description; + size_t files = 0; + bool replacesAssets = false; // has textures or models (already-loaded ones need a restart) +}; +std::vector listMods(const std::string& modsDir); +// Whether `name` is on in an fs_mods value ("all" or "a,b,c"). +bool modEnabled(const std::string& which, const std::string& name); + +std::optional> read(std::string_view path); +std::optional readText(std::string_view path); +bool exists(std::string_view path); +// When the file `path` resolves to was last written (loose files only: a pack's never changes +// under a running game); for hot reload. +std::optional mtime(std::string_view path); +// Files under `dir` (recursive) ending in `ext`, across all mounts, no duplicates. +std::vector list(std::string_view dir, std::string_view ext = {}); + +} // namespace keel::fs diff --git a/engine/fs/ymp.cpp b/engine/fs/ymp.cpp new file mode 100644 index 0000000..ccfdaac --- /dev/null +++ b/engine/fs/ymp.cpp @@ -0,0 +1,278 @@ +#include "fs/ymp.h" +#include "core/log.h" + +#include +#include + +#include +#include +#include +#include + +namespace keel { +namespace { +constexpr const char* kIndexName = ".ymp-index"; +constexpr size_t kBlock = 512; +constexpr size_t kFrameTarget = 64 * 1024; // end a frame at the next file boundary after this +constexpr unsigned kMaxFrame = 1024 * 1024; + +uint64_t roundUp(uint64_t n) { return (n + kBlock - 1) / kBlock * kBlock; } + +// ustar header; names up to 255 characters via the prefix field. +bool writeHeader(uint8_t* h, const std::string& name, uint64_t size) { + std::memset(h, 0, kBlock); + std::string prefix, base = name; + if (name.size() > 100) { + size_t cut = name.rfind('/', 155); + if (cut == std::string::npos || name.size() - cut - 1 > 100) return false; + prefix = name.substr(0, cut); + base = name.substr(cut + 1); + } + std::memcpy(h, base.data(), base.size()); + std::snprintf((char*)h + 100, 8, "%07o", 0644); + std::snprintf((char*)h + 108, 8, "%07o", 0); + std::snprintf((char*)h + 116, 8, "%07o", 0); + if (size >= (1ull << 33)) return false; // ustar size field limit: 8 GiB per file + char sizeField[24]; + std::snprintf(sizeField, sizeof sizeField, "%011" PRIo64, size); + std::memcpy(h + 124, sizeField, 12); + std::snprintf((char*)h + 136, 12, "%011o", 0); + h[156] = '0'; + std::memcpy(h + 257, "ustar", 6); + h[263] = h[264] = '0'; + std::memcpy(h + 345, prefix.data(), prefix.size()); + std::memset(h + 148, ' ', 8); + unsigned sum = 0; + for (size_t i = 0; i < kBlock; ++i) sum += h[i]; + std::snprintf((char*)h + 148, 8, "%06o", sum); + h[155] = ' '; + return true; +} + +bool readHeader(const uint8_t* h, std::string& name, uint64_t& size, char& type) { + if (h[0] == 0) return false; // end of archive + name.assign((const char*)h, strnlen((const char*)h, 100)); + if (std::memcmp(h + 257, "ustar", 5) == 0 && h[345]) { + name = std::string((const char*)h + 345, strnlen((const char*)h + 345, 155)) + "/" + name; + } + char sizeField[13] = {}; + std::memcpy(sizeField, h + 124, 12); + size = std::strtoull(sizeField, nullptr, 8); + type = char(h[156]); + return true; +} + +struct Layout { + std::string index; + std::vector dataOffsets; +}; + +// Offsets are written with fixed width, so the index size is known before the offsets are. +Layout layout(const std::vector>& files) { + size_t indexSize = 0; + for (auto& f : files) indexSize += 16 + 1 + 16 + 1 + f.first.size() + 1; + Layout l; + uint64_t pos = kBlock + roundUp(indexSize); + char line[48]; + for (auto& f : files) { + l.dataOffsets.push_back(pos + kBlock); + std::snprintf(line, sizeof line, "%016" PRIx64 " %016" PRIx64 " ", pos + kBlock, f.second); + l.index += line + f.first + "\n"; + pos += kBlock + roundUp(f.second); + } + return l; +} +} // namespace + +std::vector buildTar(const std::vector>>& files) { + std::vector> sizes; + for (auto& f : files) sizes.emplace_back(f.first, f.second.size()); + const Layout l = layout(sizes); + std::vector out; + auto append = [&](const std::string& name, const void* data, size_t n) { + size_t at = out.size(); + out.resize(at + kBlock + roundUp(n)); + writeHeader(out.data() + at, name, n); + std::memcpy(out.data() + at + kBlock, data, n); + }; + append(kIndexName, l.index.data(), l.index.size()); + for (auto& f : files) append(f.first, f.second.data(), f.second.size()); + out.resize(out.size() + 2 * kBlock); + return out; +} + +bool writeYmp(const std::string& outPath, const std::vector& files, int level) { + std::vector> sizes; + for (auto& f : files) { + std::ifstream in(f.diskPath, std::ios::binary | std::ios::ate); + if (!in) return log::error("ympack: can't read {}", f.diskPath), false; + sizes.emplace_back(f.archivePath, uint64_t(in.tellg())); + } + const Layout l = layout(sizes); + + FILE* out = std::fopen(outPath.c_str(), "wb"); + if (!out) return log::error("ympack: can't write {}", outPath), false; + ZSTD_seekable_CStream* zcs = ZSTD_seekable_createCStream(); + ZSTD_seekable_initCStream(zcs, level, 1, kMaxFrame); + std::vector outBuf(ZSTD_CStreamOutSize()); + size_t frameBytes = 0; + bool ok = true; + + auto drain = [&](ZSTD_outBuffer& ob) { + ok &= std::fwrite(outBuf.data(), 1, ob.pos, out) == ob.pos; + ob.pos = 0; + }; + auto feed = [&](const void* data, size_t n) { + ZSTD_inBuffer in{data, n, 0}; + while (in.pos < in.size) { + ZSTD_outBuffer ob{outBuf.data(), outBuf.size(), 0}; + if (ZSTD_isError(ZSTD_seekable_compressStream(zcs, &ob, &in))) ok = false; + drain(ob); + if (!ok) return; + } + frameBytes += n; + }; + auto endFrameIfBig = [&] { + if (frameBytes < kFrameTarget) return; + size_t left; + do { + ZSTD_outBuffer ob{outBuf.data(), outBuf.size(), 0}; + left = ZSTD_seekable_endFrame(zcs, &ob); + drain(ob); + } while (left > 0 && !ZSTD_isError(left)); + frameBytes = 0; + }; + auto member = [&](const std::string& name, const std::vector& data) { + uint8_t header[kBlock]; + if (!writeHeader(header, name, data.size())) { + log::error("ympack: path too long or file over 8 GiB: {}", name); + ok = false; + return; + } + static const uint8_t zeros[kBlock] = {}; + feed(header, kBlock); + feed(data.data(), data.size()); + feed(zeros, roundUp(data.size()) - data.size()); + endFrameIfBig(); + }; + + member(kIndexName, std::vector(l.index.begin(), l.index.end())); + for (size_t i = 0; i < files.size() && ok; ++i) { + std::ifstream in(files[i].diskPath, std::ios::binary); + std::vector data((std::istreambuf_iterator(in)), {}); + member(files[i].archivePath, data); + } + static const uint8_t endBlocks[2 * kBlock] = {}; + feed(endBlocks, sizeof endBlocks); + size_t left; + do { + ZSTD_outBuffer ob{outBuf.data(), outBuf.size(), 0}; + left = ZSTD_seekable_endStream(zcs, &ob); + drain(ob); + } while (left > 0 && !ZSTD_isError(left)); + ZSTD_seekable_freeCStream(zcs); + ok &= std::fclose(out) == 0; + return ok; +} + +YmpArchive::YmpArchive() = default; + +YmpArchive::~YmpArchive() { + if (seekable_) ZSTD_seekable_free(seekable_); + if (file_) std::fclose(file_); +} + +bool YmpArchive::readRange(uint64_t offset, size_t size, void* out) { + if (!memory_.empty() || !seekable_) { + if (offset + size > memory_.size()) return false; + std::memcpy(out, memory_.data() + offset, size); + return true; + } + size_t done = 0; + while (done < size) { + size_t r = ZSTD_seekable_decompress(seekable_, (uint8_t*)out + done, size - done, offset + done); + if (ZSTD_isError(r) || r == 0) return false; + done += r; + } + return true; +} + +bool YmpArchive::open(const std::string& path) { + path_ = path; + file_ = std::fopen(path.c_str(), "rb"); + if (!file_) return false; + + seekable_ = ZSTD_seekable_create(); + if (ZSTD_isError(ZSTD_seekable_initFile(seekable_, file_))) { + // Not seekable: plain .tar.zst. Decompress it all once. + ZSTD_seekable_free(seekable_); + seekable_ = nullptr; + std::fseek(file_, 0, SEEK_SET); + std::ifstream raw(path, std::ios::binary); + std::vector packed((std::istreambuf_iterator(raw)), {}); + ZSTD_DStream* ds = ZSTD_createDStream(); + ZSTD_inBuffer in{packed.data(), packed.size(), 0}; + std::vector chunk(ZSTD_DStreamOutSize()); + while (in.pos < in.size) { + ZSTD_outBuffer ob{chunk.data(), chunk.size(), 0}; + if (ZSTD_isError(ZSTD_decompressStream(ds, &ob, &in))) break; + memory_.insert(memory_.end(), chunk.data(), chunk.data() + ob.pos); + } + ZSTD_freeDStream(ds); + if (memory_.empty()) return log::warn("{} is not a zstd archive", path), false; + log::warn("{} is not seekable; loaded whole into memory (repack with ympack)", path); + return parseTar(memory_.size()); + } + + uint64_t total = 0; + const unsigned frames = ZSTD_seekable_getNumFrames(seekable_); + if (frames) total = ZSTD_seekable_getFrameDecompressedOffset(seekable_, frames - 1) + ZSTD_seekable_getFrameDecompressedSize(seekable_, frames - 1); + + // Fast path: the index member. + uint8_t header[kBlock]; + std::string name; + uint64_t size = 0; + char type = 0; + if (readRange(0, kBlock, header) && readHeader(header, name, size, type) && name == kIndexName) { + std::string index(size, '\0'); + if (!readRange(kBlock, size, index.data())) return false; + size_t pos = 0; + while (pos < index.size()) { + size_t eol = index.find('\n', pos); + if (eol == std::string::npos || eol - pos < 35) break; + Entry e{std::strtoull(index.c_str() + pos, nullptr, 16), std::strtoull(index.c_str() + pos + 17, nullptr, 16)}; + entries_[index.substr(pos + 34, eol - pos - 34)] = e; + pos = eol + 1; + } + return true; + } + return parseTar(total); +} + +bool YmpArchive::parseTar(uint64_t totalSize) { + uint64_t pos = 0; + uint8_t header[kBlock]; + std::string name; + uint64_t size; + char type; + while (pos + kBlock <= totalSize && readRange(pos, kBlock, header) && readHeader(header, name, size, type)) { + if ((type == '0' || type == 0) && name != kIndexName) entries_[name] = {pos + kBlock, size}; + pos += kBlock + roundUp(size); + } + return true; +} + +bool YmpArchive::read(std::string_view path, std::vector& out) { + auto it = entries_.find(path); + if (it == entries_.end()) return false; + out.resize(it->second.size); + return readRange(it->second.offset, out.size(), out.data()); +} + +std::vector YmpArchive::files() const { + std::vector out; + for (auto& [name, _] : entries_) out.push_back(name); + return out; +} + +} // namespace keel diff --git a/engine/fs/ymp.h b/engine/fs/ymp.h new file mode 100644 index 0000000..2122d78 --- /dev/null +++ b/engine/fs/ymp.h @@ -0,0 +1,57 @@ +#pragma once +// YMP ("Your Mom's Package") = a tar archive compressed with zstd's seekable +// format. It is a normal .tar.zst (`tar --zstd -xf pak0.ymp` works), and the +// engine can still read any file without decompressing the rest: +// - frames end at file boundaries, so a small file decompresses on its own +// - the first tar member, ".ymp-index", lists every file's offset and size +// Plain .tar.zst files (no index, not seekable) also load: they are fully +// decompressed into memory once. +#include +#include +#include +#include +#include +#include + +struct ZSTD_seekable_s; + +namespace keel { + +struct PackInput { + std::string archivePath; // path inside the pack, forward slashes + std::string diskPath; +}; + +// Writes a pack. Level 19 is slow to build and fast to load; 3 is quick for iteration. +bool writeYmp(const std::string& outPath, const std::vector& files, int level = 19); +// Builds an uncompressed tar (with the YMP index as first member). +std::vector buildTar(const std::vector>>& files); + +class YmpArchive { +public: + YmpArchive(); + ~YmpArchive(); + YmpArchive(const YmpArchive&) = delete; + YmpArchive& operator=(const YmpArchive&) = delete; + + bool open(const std::string& path); + bool read(std::string_view path, std::vector& out); + bool contains(std::string_view path) const { return entries_.count(std::string(path)) != 0; } + std::vector files() const; + +private: + struct Entry { + uint64_t offset; // in the uncompressed tar + uint64_t size; + }; + bool readRange(uint64_t offset, size_t size, void* out); + bool parseTar(uint64_t totalSize); + + std::string path_; + FILE* file_ = nullptr; + ::ZSTD_seekable_s* seekable_ = nullptr; + std::vector memory_; // whole tar, when the archive isn't seekable + std::map> entries_; +}; + +} // namespace keel diff --git a/engine/platform/input.cpp b/engine/platform/input.cpp new file mode 100644 index 0000000..bcd6408 --- /dev/null +++ b/engine/platform/input.cpp @@ -0,0 +1,171 @@ +#include "platform/input.h" +#include "platform/touch.h" +#include "core/cvar.h" +#include "core/log.h" + +#include + +#include +#include +#include + +namespace keel::input { +namespace { +float g_dx = 0, g_dy = 0; +bool g_captured = false; +bool g_noCapture = false; +std::map g_binds; // key name → command +std::set g_held; // keys whose "+" command is active +void (*g_capture)(const char*) = nullptr; + +std::string lower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return char(std::tolower(c)); }); + return s; +} + +std::string keyName(SDL_Scancode sc) { + switch (sc) { + case SDL_SCANCODE_LCTRL: case SDL_SCANCODE_RCTRL: return "ctrl"; + case SDL_SCANCODE_LSHIFT: case SDL_SCANCODE_RSHIFT: return "shift"; + case SDL_SCANCODE_LALT: case SDL_SCANCODE_RALT: return "alt"; + default: return lower(SDL_GetScancodeName(sc)); + } +} + +void press(const std::string& key, bool down) { + if (down && g_capture) { + auto cb = g_capture; + g_capture = nullptr; + cb(key.c_str()); + return; + } + auto it = g_binds.find(key); + if (it == g_binds.end()) return; + const std::string& cmd = it->second; + if (cmd.starts_with('+')) { + if (down && g_held.insert(key).second) execute(cmd); + else if (!down && g_held.erase(key)) execute("-" + cmd.substr(1)); + } else if (down) { + execute(cmd); + } +} + +Cmd cmd_bind("bind", [](const Args& a) { + if (a.size() < 2) return log::info("usage: bind [command]"); + if (a.size() == 2) { + auto it = g_binds.find(lower(a[1])); + return log::info("\"{}\" = \"{}\"", a[1], it == g_binds.end() ? "" : it->second); + } + std::string cmd; + for (size_t i = 2; i < a.size(); ++i) cmd += (i > 2 ? " " : "") + a[i]; + bind(a[1].c_str(), cmd.c_str()); +}, "Bind a key to a command"); +Cmd cmd_unbind("unbind", [](const Args& a) { + if (a.size() > 1) g_binds.erase(lower(a[1])); +}, "Remove a key binding"); +Cmd cmd_unbindall("unbindall", [](const Args&) { g_binds.clear(); }, "Remove all key bindings"); +Cmd cmd_bindlist("key_listboundkeys", [](const Args&) { + for (auto& [k, v] : g_binds) log::info(" \"{}\" = \"{}\"", k, v); + log::info("{} keys bound", g_binds.size()); +}, "List every key binding"); +Cmd cmd_bindlist2("bindlist", [](const Args&) { execute("key_listboundkeys"); }, "List every key binding"); +// Key names for the console's suggestions. +std::vector keyNames() { + std::vector v; + for (char c = 'a'; c <= 'z'; ++c) v.emplace_back(1, c); + for (char c = '0'; c <= '9'; ++c) v.emplace_back(1, c); + for (int f = 1; f <= 12; ++f) v.push_back("f" + std::to_string(f)); + for (const char* k : {"space", "tab", "escape", "return", "backspace", "ctrl", "shift", "alt", "left ctrl", "left shift", "left alt", "up", "down", + "left", "right", "insert", "delete", "home", "end", "pageup", "pagedown", "mouse1", "mouse2", "mouse3", "mouse4", "mouse5", + "mwheelup", "mwheeldown", "`", "-", "=", "[", "]", ";", "'", ",", ".", "/"}) + v.emplace_back(k); + return v; +} +[[maybe_unused]] const bool g_keyCompleters = [] { + cmd_bind.complete([](std::string_view p, size_t arg) { return matching(arg == 1 ? keyNames() : allNames(), p); }); + cmd_unbind.complete([](std::string_view p, size_t) { + std::vector v; + for (auto& [k, _] : g_binds) v.push_back(k); + return matching(v, p); + }); + return true; +}(); +} // namespace + +void bind(const char* key, const char* command) { + if (!*command) g_binds.erase(lower(key)); + else g_binds[lower(key)] = command; +} + +std::string bindingsConfig() { + std::string out = "unbindall\n"; + for (auto& [k, v] : g_binds) out += "bind \"" + k + "\" \"" + v + "\"\n"; + return out; +} + +std::string keyFor(const char* command) { + for (auto& [k, v] : g_binds) + if (v == command) return k; + return {}; +} + +void captureNextKey(void (*callback)(const char*)) { g_capture = callback; } + +void releaseAll() { + for (const std::string& key : std::set(g_held)) press(key, false); +} + +bool keyDown(int scancode) { + if (!g_captured) return false; + int n = 0; + const bool* keys = SDL_GetKeyboardState(&n); + return scancode >= 0 && scancode < n && keys[scancode]; +} + +bool mouseDown(int button) { return g_captured && (SDL_GetMouseState(nullptr, nullptr) & SDL_BUTTON_MASK(button)); } + +void takeMouseDelta(float& dx, float& dy) { + float tx, ty; // looking with a finger counts as the mouse + touch::takeLook(tx, ty); + dx = g_dx + tx; + dy = g_dy + ty; + g_dx = g_dy = 0; +} + +bool captured() { return g_captured; } +void disableCapture() { g_noCapture = true; } + +void setCaptured(bool on) { + if (on && g_noCapture) return; + if (on == g_captured) return; + g_captured = on; + if (!on) releaseAll(), touch::releaseAll(); + if (SDL_Window* w = SDL_GetKeyboardFocus()) SDL_SetWindowRelativeMouseMode(w, on); + g_dx = g_dy = 0; +} + +void handleEvent(const SDL_Event& ev) { + // While playing, touches are the touch controls (platform/touch.cpp), not a mouse. + if ((ev.type == SDL_EVENT_MOUSE_MOTION && ev.motion.which == SDL_TOUCH_MOUSEID) || + ((ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN || ev.type == SDL_EVENT_MOUSE_BUTTON_UP) && ev.button.which == SDL_TOUCH_MOUSEID)) { + if (g_captured) return; + } + if (ev.type == SDL_EVENT_MOUSE_MOTION && g_captured) { + g_dx += ev.motion.xrel; + g_dy += ev.motion.yrel; + } + if (!g_captured && !g_capture) return; + if ((ev.type == SDL_EVENT_KEY_DOWN || ev.type == SDL_EVENT_KEY_UP) && !ev.key.repeat) + press(keyName(ev.key.scancode), ev.type == SDL_EVENT_KEY_DOWN); + if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN || ev.type == SDL_EVENT_MOUSE_BUTTON_UP) + // Source's names: mouse1 left, mouse2 right, mouse3 middle (SDL has middle = 2, right = 3). + press("mouse" + std::to_string(ev.button.button == SDL_BUTTON_RIGHT ? 2 : ev.button.button == SDL_BUTTON_MIDDLE ? 3 : ev.button.button), + ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN); + if (ev.type == SDL_EVENT_MOUSE_WHEEL && ev.wheel.y != 0) { + const std::string key = ev.wheel.y > 0 ? "mwheelup" : "mwheeldown"; + press(key, true); + press(key, false); + } +} + +} // namespace keel::input diff --git a/engine/platform/input.h b/engine/platform/input.h new file mode 100644 index 0000000..738c70f --- /dev/null +++ b/engine/platform/input.h @@ -0,0 +1,35 @@ +#pragma once +#include + +union SDL_Event; + +namespace keel::input { + +bool keyDown(int scancode); // SDL_SCANCODE_* +bool mouseDown(int button); // SDL_BUTTON_LEFT etc. +// Mouse movement since the last call, in pixels. +void takeMouseDelta(float& dx, float& dy); +// While captured the cursor is hidden and the game owns the mouse and keyboard. +bool captured(); + +// Key bindings, Quake/Source style: "bind w +forward" runs "+forward" on press +// and "-forward" on release; other commands run on press. Key names: letters, +// digits, "space", "ctrl", "shift", "escape", "f1".., "mouse1".."mouse5", +// "mwheelup", "mwheeldown". +void bind(const char* key, const char* command); +// Lines "unbindall" + "bind ..." for config.cfg. +std::string bindingsConfig(); +// The key bound to `command` ("" if none), for menus. +std::string keyFor(const char* command); +// The next key pressed is reported here instead of running its binding (options menu). +void captureNextKey(void (*callback)(const char* keyName)); + +// Engine side. +void setCaptured(bool on); +// Never grab the mouse (test mode). +void disableCapture(); +void handleEvent(const SDL_Event& ev); +// Releases every held "+" action (when a menu opens mid-press). +void releaseAll(); + +} // namespace keel::input diff --git a/engine/platform/touch.cpp b/engine/platform/touch.cpp new file mode 100644 index 0000000..cb6b1e1 --- /dev/null +++ b/engine/platform/touch.cpp @@ -0,0 +1,190 @@ +#include "platform/touch.h" +#include "core/cvar.h" + +#include +#include + +#include +#include +#include +#include + +namespace keel::touch { +namespace { +#if defined(__ANDROID__) +Cvar touch_controls("touch_controls", "1", CVAR_ARCHIVE, "On-screen touch controls (0 off, 1 on)"); +#else +Cvar touch_controls("touch_controls", "1", CVAR_ARCHIVE, "On-screen touch controls once the screen is touched (0 never)"); +#endif +Cvar touch_sensitivity("touch_sensitivity", "1.6", CVAR_ARCHIVE, "How far looking turns for a finger's drag"); +Cvar touch_size("touch_size", "1", CVAR_ARCHIVE, "Size of the touch buttons and stick"); + +// A button: its command ("+x" held, else run once), label, and where (from the bottom-right +// corner, in shorter-screen-sides), how big. +struct Button { + const char* cmd; + const char* label; + float x, y, r; +}; +constexpr Button kButtons[] = { + {"+attack", "FIRE", 0.15f, 0.34f, 0.085f}, {"+jump", "JUMP", 0.33f, 0.14f, 0.07f}, {"+duck", "DUCK", 0.13f, 0.13f, 0.06f}, + {"+use", "USE", 0.34f, 0.36f, 0.055f}, {"+attack2", "ALT", 0.13f, 0.57f, 0.055f}, {"+sprint", "RUN", 0.50f, 0.12f, 0.055f}, + {"flashlight", "LIGHT", 0.32f, 0.57f, 0.045f}, {"backpack", "BAG", 0.47f, 0.32f, 0.045f}, +}; +constexpr int kButtonCount = int(sizeof kButtons / sizeof kButtons[0]); + +struct Finger { + enum Role { Look, Stick, ButtonHeld, Pause } role; + float x, y; // where it is now (pixels) + float ox, oy; // the stick's centre + int button = -1; +}; +std::unordered_map g_fingers; +bool g_used = false, g_escape = false; +float g_lookX = 0, g_lookY = 0; +bool g_held[kButtonCount] = {}; + +#if defined(__ANDROID__) +constexpr bool kAlways = true; +#else +constexpr bool kAlways = false; +#endif + +ImVec2 screen() { + const ImVec2 s = ImGui::GetIO().DisplaySize; + return s.x > 0 ? s : ImVec2(1280, 720); +} +float unit() { // the shorter side, scaled + const ImVec2 s = screen(); + return std::min(s.x, s.y) * std::clamp(touch_size.f(), 0.5f, 2.0f); +} +ImVec2 buttonAt(const Button& b) { + const ImVec2 s = screen(); + const float m = unit(); + return ImVec2(s.x - b.x * m, s.y - b.y * m); +} +ImVec2 pauseAt() { return ImVec2(unit() * 0.07f, unit() * 0.07f); } +void press(int i, bool down) { + const Button& b = kButtons[i]; + if (b.cmd[0] == '+') { + if (down == g_held[i]) return; + g_held[i] = down; + execute(down ? b.cmd : ("-" + std::string(b.cmd + 1))); + } else if (down) { + execute(b.cmd); + } +} +} // namespace + +bool active() { return touch_controls.b() && (kAlways || g_used); } + +void handleEvent(const SDL_Event& ev, bool inGame) { + if (ev.type != SDL_EVENT_FINGER_DOWN && ev.type != SDL_EVENT_FINGER_MOTION && ev.type != SDL_EVENT_FINGER_UP) return; + g_used = true; + const ImVec2 s = screen(); + const float x = ev.tfinger.x * s.x, y = ev.tfinger.y * s.y; + if (!inGame || !active()) { // menus: SDL's mouse emulation does the taps + if (ev.type == SDL_EVENT_FINGER_UP) g_fingers.erase(ev.tfinger.fingerID); + return; + } + const float m = unit(); + if (ev.type == SDL_EVENT_FINGER_DOWN) { + Finger f{Finger::Look, x, y, x, y}; + const ImVec2 p = pauseAt(); + if (std::hypot(x - p.x, y - p.y) < m * 0.06f) { + f.role = Finger::Pause; + g_escape = true; + } else { + for (int i = 0; i < kButtonCount; ++i) { + const ImVec2 c = buttonAt(kButtons[i]); + if (std::hypot(x - c.x, y - c.y) < kButtons[i].r * m * 1.25f) { + f.role = Finger::ButtonHeld, f.button = i; + press(i, true); + break; + } + } + if (f.role == Finger::Look && x < s.x * 0.4f && y > s.y * 0.3f) f.role = Finger::Stick; // the left thumb's area + } + g_fingers[ev.tfinger.fingerID] = f; + } else if (ev.type == SDL_EVENT_FINGER_MOTION) { + auto it = g_fingers.find(ev.tfinger.fingerID); + if (it == g_fingers.end()) return; + Finger& f = it->second; + if (f.role == Finger::Look) { + g_lookX += (x - f.x) * touch_sensitivity.f(); + g_lookY += (y - f.y) * touch_sensitivity.f(); + } + f.x = x, f.y = y; + if (f.role == Finger::Stick) { // the stick's centre follows a thumb that goes past its edge + const float r = m * 0.11f, dx = x - f.ox, dy = y - f.oy, d = std::hypot(dx, dy); + if (d > r) f.ox = x - dx / d * r, f.oy = y - dy / d * r; + } + } else { + auto it = g_fingers.find(ev.tfinger.fingerID); + if (it == g_fingers.end()) return; + if (it->second.role == Finger::ButtonHeld) press(it->second.button, false); + g_fingers.erase(it); + } +} + +void move(float& forward, float& side) { + forward = side = 0.0f; + const float r = unit() * 0.11f; + for (const auto& [id, f] : g_fingers) + if (f.role == Finger::Stick) { + side = std::clamp((f.x - f.ox) / r, -1.0f, 1.0f); + forward = std::clamp(-(f.y - f.oy) / r, -1.0f, 1.0f); + } +} + +void takeLook(float& dx, float& dy) { + dx = g_lookX, dy = g_lookY; + g_lookX = g_lookY = 0; +} + +bool takeEscape() { return std::exchange(g_escape, false); } + +void releaseAll() { + for (int i = 0; i < kButtonCount; ++i) + if (g_held[i]) press(i, false); + g_fingers.clear(); +} + +void draw(bool inGame) { + if (!inGame || !active()) return; + ImDrawList* dl = ImGui::GetForegroundDrawList(); + const float m = unit(); + const ImU32 ring = IM_COL32(255, 255, 255, 110), fill = IM_COL32(20, 20, 24, 70), hot = IM_COL32(220, 40, 40, 150), text = IM_COL32(255, 255, 255, 200); + auto label = [&](ImVec2 c, const char* t) { + const ImVec2 sz = ImGui::CalcTextSize(t); + dl->AddText(ImVec2(c.x - sz.x * 0.5f, c.y - sz.y * 0.5f), text, t); + }; + for (int i = 0; i < kButtonCount; ++i) { + const ImVec2 c = buttonAt(kButtons[i]); + const float r = kButtons[i].r * m; + dl->AddCircleFilled(c, r, g_held[i] ? hot : fill, 40); + dl->AddCircle(c, r, ring, 40, 2.0f); + label(c, kButtons[i].label); + } + const ImVec2 p = pauseAt(); + dl->AddCircleFilled(p, m * 0.045f, fill, 32); + dl->AddCircle(p, m * 0.045f, ring, 32, 2.0f); + dl->AddRectFilled(ImVec2(p.x - m * 0.016f, p.y - m * 0.02f), ImVec2(p.x - m * 0.006f, p.y + m * 0.02f), text); + dl->AddRectFilled(ImVec2(p.x + m * 0.006f, p.y - m * 0.02f), ImVec2(p.x + m * 0.016f, p.y + m * 0.02f), text); + // The stick, where the thumb is (or a hint of where it goes). + const ImVec2 s = screen(); + bool stick = false; + for (const auto& [id, f] : g_fingers) + if (f.role == Finger::Stick) { + stick = true; + dl->AddCircleFilled(ImVec2(f.ox, f.oy), m * 0.11f, fill, 48); + dl->AddCircle(ImVec2(f.ox, f.oy), m * 0.11f, ring, 48, 2.0f); + const float dx = f.x - f.ox, dy = f.y - f.oy, d = std::hypot(dx, dy), k = d > m * 0.11f ? m * 0.11f / d : 1.0f; + dl->AddCircleFilled(ImVec2(f.ox + dx * k, f.oy + dy * k), m * 0.045f, hot, 32); + } + if (!stick) { + const ImVec2 c(m * 0.22f, s.y - m * 0.22f); + dl->AddCircle(c, m * 0.11f, IM_COL32(255, 255, 255, 50), 48, 2.0f); + } +} +} // namespace keel::touch diff --git a/engine/platform/touch.h b/engine/platform/touch.h new file mode 100644 index 0000000..f3b34f5 --- /dev/null +++ b/engine/platform/touch.h @@ -0,0 +1,22 @@ +#pragma once +// Touch controls (phones, tablets, touch screens): while you play, a stick under your left thumb +// moves, dragging on the right looks, and buttons on the right press the same "+jump", +// "+attack"... a key would. In menus, touches are taps (SDL turns them into mouse clicks). +// They show on Android, and anywhere once a finger touches the screen (touch_controls 0: never). +union SDL_Event; + +namespace keel::touch { +bool active(); +// inGame: playing (the mouse is captured), when touches are controls, not taps. +void handleEvent(const SDL_Event& ev, bool inGame); +// The stick: forward and sideways, -1..1. +void move(float& forward, float& side); +// Looking since the last call, in mouse pixels (so mouse sensitivity applies). +void takeLook(float& dx, float& dy); +// The pause button was pressed (once). +bool takeEscape(); +// Draws the controls over the game (playing only). +void draw(bool inGame); +// Lets go of everything held (a menu opened). +void releaseAll(); +} // namespace keel::touch diff --git a/engine/platform/window.cpp b/engine/platform/window.cpp new file mode 100644 index 0000000..00ca734 --- /dev/null +++ b/engine/platform/window.cpp @@ -0,0 +1,98 @@ +#include "platform/window.h" +#include "core/log.h" + +#include + +#include +#include +#include + +#ifdef _WIN32 +// Hybrid laptops: ask the NVIDIA / AMD drivers for the dedicated GPU (read from the executable). +extern "C" { +__declspec(dllexport) unsigned long NvOptimusEnablement = 1; +__declspec(dllexport) int AmdPowerXpressRequestHighPerformance = 1; +} +#endif + +namespace keel { +namespace { +// Hybrid laptops on Linux: OpenGL goes to whatever GPU runs the desktop (often the integrated +// one) unless asked. Vulkan picks the discrete GPU itself; this makes OpenGL do the same. Set +// before SDL loads the GL library; anything the user already set wins. KEEL_IGPU=1 opts out. +void preferDedicatedGpu() { +#ifndef _WIN32 + if (std::getenv("KEEL_IGPU")) return; + if (std::filesystem::exists("/proc/driver/nvidia")) { // NVIDIA's own driver: PRIME render offload + setenv("__NV_PRIME_RENDER_OFFLOAD", "1", 0); + setenv("__GLX_VENDOR_LIBRARY_NAME", "nvidia", 0); + setenv("__VK_LAYER_NV_optimus", "NVIDIA_only", 0); + } else { + setenv("DRI_PRIME", "1", 0); // Mesa: the other GPU if there is one, else the same + } +#endif +} +} // namespace + +bool Window::init(const char* title, int width, int height, bool opengl) { + preferDedicatedGpu(); + if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS)) { + log::error("SDL_Init failed: {}", SDL_GetError()); + return false; + } + window_ = SDL_CreateWindow(title, width, height, (opengl ? SDL_WINDOW_OPENGL : SDL_WINDOW_VULKAN) | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY); + if (!window_) { + log::error("SDL_CreateWindow failed: {}", SDL_GetError()); + return false; + } + return true; +} + +void Window::shutdown() { + if (window_) SDL_DestroyWindow(window_); + window_ = nullptr; + SDL_Quit(); +} + +void Window::setIcon(const uint8_t* rgba, int width, int height) { + if (SDL_Surface* s = SDL_CreateSurfaceFrom(width, height, SDL_PIXELFORMAT_ABGR8888, const_cast(rgba), width * 4)) { + SDL_SetWindowIcon(window_, s); + SDL_DestroySurface(s); + } +} + +void Window::setMode(int mode, int width, int height) { + if (!window_) return; + if (mode == 0) { + SDL_SetWindowFullscreen(window_, false); + SDL_SetWindowSize(window_, width, height); + SDL_SetWindowPosition(window_, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED); + return; + } + SDL_DisplayMode closest{}; + const bool exclusive = mode == 2 && SDL_GetClosestFullscreenDisplayMode(SDL_GetDisplayForWindow(window_), width, height, 0.0f, true, &closest); + SDL_SetWindowFullscreenMode(window_, exclusive ? &closest : nullptr); // nullptr = borderless desktop + SDL_SetWindowFullscreen(window_, true); +} + +std::vector> Window::resolutions() const { + std::vector> out; + int count = 0; + if (SDL_DisplayMode** modes = SDL_GetFullscreenDisplayModes(SDL_GetDisplayForWindow(window_), &count)) { + for (int i = 0; i < count; ++i) { + const std::pair r{modes[i]->w, modes[i]->h}; + if (std::find(out.begin(), out.end(), r) == out.end()) out.push_back(r); + } + SDL_free(modes); + } + return out; +} + +void Window::pixelSize(uint32_t& w, uint32_t& h) const { + int iw = 0, ih = 0; + if (!(SDL_GetWindowFlags(window_) & SDL_WINDOW_MINIMIZED)) SDL_GetWindowSizeInPixels(window_, &iw, &ih); + w = uint32_t(iw); + h = uint32_t(ih); +} + +} // namespace keel diff --git a/engine/platform/window.h b/engine/platform/window.h new file mode 100644 index 0000000..66dd0ab --- /dev/null +++ b/engine/platform/window.h @@ -0,0 +1,31 @@ +#pragma once +#include + +#include +#include +#include + +struct SDL_Window; + +namespace keel { + +class Window { +public: + // opengl: a window for an OpenGL context (the backend creates it); otherwise Vulkan. + bool init(const char* title, int width, int height, bool opengl = false); + void shutdown(); + SDL_Window* handle() const { return window_; } + // Size in pixels (not points); 0×0 while minimized. + void pixelSize(uint32_t& w, uint32_t& h) const; + // mode: 0 = windowed (width × height), 1 = borderless fullscreen (desktop size), + // 2 = exclusive fullscreen at the display mode closest to width × height. + void setMode(int mode, int width, int height); + void setIcon(const uint8_t* rgba, int width, int height); + // Fullscreen resolutions the display offers, largest first, no duplicates. + std::vector> resolutions() const; + +private: + SDL_Window* window_ = nullptr; +}; + +} // namespace keel diff --git a/engine/render/backend.h b/engine/render/backend.h new file mode 100644 index 0000000..9bb76e0 --- /dev/null +++ b/engine/render/backend.h @@ -0,0 +1,240 @@ +#pragma once +// The line between the renderer and a graphics API. The Renderer (render/renderer.cpp) does +// everything that isn't API work — culling, lights and clusters, shadow cascades, sorting — +// and hands the result over each frame as a FramePacket; a RenderBackend (Vulkan or OpenGL) +// only owns GPU objects and records the passes. Both backends run the same shaders +// (engine/shaders), so the structures below match their layouts exactly. +#include "asset/kmdl.h" +#include "render/clusters.h" + +#include + +#include +#include +#include +#include + +namespace keel { + +class Window; +using TextureId = uint32_t; + +// OpenGL40/OpenGL31: the cut-down fallback for old GPUs (rhi/gl/gl_legacy.cpp). +enum class RenderApi { Vulkan, OpenGL, OpenGL40, OpenGL31 }; + +// Texture slots. 0 and 1 are created by the renderer; 2-4 are the backend's render targets +// (sampled through the same table); loaded textures start at kFirstTextureSlot. +inline constexpr TextureId kWhiteTexture = 0, kMissingTexture = 1, kSceneSlot = 2, kSunShadowSlot = 3, kAtlasSlot = 4, kDepthSlot = 5; +// Post-processing targets: occlusion (raw, blurred), bloom levels (half size down to 1/64). +inline constexpr TextureId kAoSlot = 6, kAoBlurSlot = 7, kBloomSlot = 8; +inline constexpr uint32_t kBloomLevels = 6; +inline constexpr TextureId kTaaSlot = kBloomSlot + kBloomLevels; // two: this frame's and last frame's +// The mesh pass's G-buffer (view normal, roughness, reflection weight) and the reflections from it. +inline constexpr TextureId kGbufSlot = kTaaSlot + 2, kSsrSlot = kGbufSlot + 1; +inline constexpr TextureId kAdaptSlot = kSsrSlot + 1; // two (1×1): eye adaptation, this frame's and last frame's +// The occlusion grid and its pyramid (each level half the last, the farthest depth under each +// texel), for GPU culling: kHzbLevels slots from kHzbSlot. +inline constexpr uint32_t kHzbLevels = 8; // 256×128 down to 2×1 +inline constexpr TextureId kHzbSlot = kAdaptSlot + 2; +inline constexpr TextureId kMirrorSlot = kHzbSlot + kHzbLevels; // what the nearest mirror shows +inline constexpr TextureId kFirstTextureSlot = kMirrorSlot + 1; +inline constexpr uint32_t kHzbWidth = 256, kHzbHeight = 128; // the occlusion grid read back to the CPU +inline constexpr uint32_t kMaxTextures = 4096; + +inline constexpr uint32_t kShadowSize = 4096; // sun: 2×2 cascades (the default size; r_shadowres) +inline constexpr uint32_t kAtlasSize = 4096; // dynamic lights' shadow tiles +inline constexpr size_t kMaxDraws = 8192; +inline constexpr size_t kMaxJointsPerFrame = 16384; +inline constexpr uint32_t kMaxLights = 1024, kMaxShadowViews = 96, kMaxLightIndices = 1 << 16; + +namespace gpu { +struct DrawData { // DRAW_FIELDS in model.glsl + glm::mat4 model; + glm::vec4 cube[6]; + glm::vec4 params; // x = sun visibility, y = lit by probes, z = liquid cut, w = LiquidStyle + glm::vec4 plane; // liquid surface + glm::vec4 probe; // the nearest reflection probe: x = its texture (0 = none), y = its top level; z = time (s) +}; +struct FrameData { // FRAME_FIELDS in model.glsl, then (Vulkan) four buffer addresses + glm::mat4 cascades[4]; + glm::vec4 splits, cascadeTexel; + glm::vec4 sunDir, sunColor, shadow; + glm::vec4 volumetric; // x strength (0 = off), y max distance, z air density + glm::vec4 camPos, camFwd, screen; + glm::vec4 sky, fog; // sky texture / intensity / top level / yaw; fog density / falloff / max / base z + glm::vec4 skySH[9]; + glm::vec4 froxel{0.0f}; // the fog grid: x 1 = on, y near, z far (froxel.glsl) + glm::vec4 gi{0.0f}, giOrigin{0.0f}; // GI probes: x 1 = on, y spacing; the grid's first cell (gi.glsl) + glm::vec4 mirror{0.0f}, clip{0.0f}; // mirrors: the picture's texture / its own pass; that pass's cut plane + glm::vec4 weather{0.0f}; // x wet, y snow cover, z night, w lightning flash (Renderer::setWeather) + uint64_t lights = 0, cells = 0, lightIndices = 0, shadowViews = 0, froxels = 0, giProbes = 0; // filled by the Vulkan backend +}; +struct Light { // Light in model.glsl + glm::vec4 posRadius, color, spot, extra; +}; +struct ShadowView { // ShadowView in model.glsl + glm::mat4 viewProj; + glm::vec4 rect; +}; +struct SkyPush { // sky.frag's push block + glm::mat4 invViewProj; + glm::vec4 sunDir, sunColor, sky; + glm::vec4 weather; // x cloud cover, y the photo's sun height (sky.frag), z night (stars), w lightning flash +}; +struct Sprite { // sprite.vert's push block (after viewProj) + glm::vec4 posSize, color, right, up; // right.w rotation, up.w texture index (as bits) +}; +struct Box { // box.vert's push block (after viewProj) + glm::vec4 center, extents, color; +}; +} // namespace gpu + +// One indexed draw of a model's submesh. +struct MeshDraw { + uint32_t model; // backend model slot + uint32_t firstIndex, indexCount; + uint32_t draw; // into FramePacket::draws + uint32_t firstJoint; // into FramePacket::joints; UINT32_MAX = not skinned + uint32_t textures, tint, material; + bool liquid = false; // cut at its surface in the shader: not in the depth pre-pass + glm::vec3 lo{0.0f}, hi{0.0f}; // world box (opaque scene draws: GPU occlusion culling) +}; + +// Something rays can hit (real-time GI): a model's submesh where it stands, and its look. +struct RayInstance { + glm::mat4 transform; + uint32_t model, firstIndex, textures, tint; +}; + +// A shadow map render: a view and a rectangle of the target, in pixels from the top-left. +struct ShadowPass { + glm::mat4 viewProj; + glm::ivec4 rect; + const std::vector* draws; +}; + +// Volumetric smoke's grid (the fog grid reads it, Vulkan): kSmokeDims cells of kSmokeCell units. +inline constexpr glm::ivec3 kSmokeDims{160, 160, 48}; +inline constexpr float kSmokeCell = 16.0f; + +struct FramePacket { + uint32_t width = 0, height = 0; // the 3D scene's size (render scale applied) + uint32_t outWidth = 0, outHeight = 0; // the window's: the tonemap scales the scene up to it, the UI is drawn at it + bool prepass = true; // r_prepass: depth first, then each pixel shaded once + // GPU occlusion culling (r_gpuculling): the opaque draws are tested on the GPU against the + // occlusion grid, made from last frame (cullViewProj); cullOcclusion: the camera's close + // enough to that frame's for the test to hold. + bool gpuCull = false, cullOcclusion = false; + std::vector rayInstances; // real-time GI: the opaque, still-shaped world (not skinned) + // Smoke: kSmokeDims cells from smokeOrigin (its world corner; w a clock that turns its + // billows), each density | shade << 8 (0..255); empty: none. + std::vector smoke; + glm::vec4 smokeOrigin{0.0f}; + // A mirror: the scene again from the eye reflected in its plane (into kMirrorSlot, the + // scene's size), before the scene pass, with its own frame data (mirrorFrame). + float skyCloud = 0.0f; // overcast: the sky greys and the sun's disc goes (sky.frag) + float skyPhotoSunZ = 2.0f; // how high the sky photo's own sun is (2: none), to hide it when the sun has moved + bool mirror = false; + glm::mat4 mirrorViewProj{1.0f}, mirrorInvViewProj{1.0f}; + glm::ivec4 mirrorRect{0}; // where the mirror is on screen (x, y, w, h in pixels): only that's drawn + gpu::FrameData mirrorFrame{}; + std::vector mirrorOpaque, mirrorTranslucent; + glm::mat4 cullViewProj{1.0f}; + glm::mat4 viewProj{1.0f}; + gpu::FrameData frame{}; + std::vector draws; + std::vector joints; + std::vector lights; + std::vector views; + const ClusterGrid* clusters = nullptr; + std::vector sunPasses; // into the sun's shadow map (kSunShadowSlot) + std::vector atlasPasses; // into the dynamic lights' atlas (kAtlasSlot) + std::vector boxes; + std::vector sprites, glowSprites; // particles: alpha-blended (back to front), additive + std::vector opaque; + std::vector translucent; // already sorted back to front + glm::vec3 clearColor{0.0f}; // linear, before exposure (when there's no sky) + glm::mat4 invViewProj{1.0f}; // for the sky pass (drawn when frame.sky.x != 0) + float exposure = 1.0f; + std::string screenshot; // save this frame as PNG here, if set + uint32_t shadowSize = kShadowSize; // the sun's shadow map (2×2 cascades); the backend resizes to it + uint64_t hzbTag = 0; // nonzero: build the occlusion grid and read it back with this tag + // Post-processing: ambient occlusion (0 = off), bloom (0 = off), grade. + struct Post { + float aoStrength = 1.0f, aoRadius = 24.0f; + float bloomStrength = 0.035f; + float saturation = 1.05f, vignette = 0.25f, sharpen = 0.3f; + glm::vec4 proj{1.0f, 1.0f, 2.0f, 16384.0f}; // tan(half fov x), tan(half fov y), near, far + bool taa = false, taaReset = true; + // Sun shafts (screen-space light scattering): the sun's screen position (uv) and + // strength (z, 0 = off), and its colour × intensity. + glm::vec4 shafts{0.0f}, sunColor{0.0f}; + float ssr = 0.0f; // screen-space reflections' strength (0 = off) + // Eye adaptation: on, the frame's seconds, the key (mid grey's target), reset (no history). + bool adapt = false, adaptReset = true; + float adaptDt = 0.016f, adaptKey = 0.18f; + glm::mat4 reproject{1.0f}; // this frame's clip space (unjittered) → last frame's + } post; +}; + +// The occlusion grid of an earlier frame (depth, kHzbWidth × kHzbHeight, row 0 = clip y −1, as +// the depth buffer's rows), and the tag that frame was given. +struct HzbReadback { + uint64_t tag = 0; + std::vector depth; +}; + +// How a texture's mips are laid out. +enum class TexFormat : uint8_t { RGBA8, BC7 }; + +// The GPU and its memory (the debug overlay's corner). VRAM: the device-local heaps; used is all +// processes' use where the driver says (VK_EXT_memory_budget), else just ours. +struct GpuInfo { + std::string name, driver; + uint64_t vramTotal = 0, vramUsed = 0, vramOurs = 0; +}; + +class RenderBackend { +public: + virtual ~RenderBackend() = default; + virtual const char* name() const = 0; + virtual GpuInfo gpuInfo() const { return {}; } + virtual bool init(const Window& window, bool validation) = 0; + virtual void shutdown() = 0; + // Dear ImGui: the backend's NewFrame (renderer + platform). + virtual void beginUi() = 0; + virtual void setVsync(bool on) = 0; + virtual void onResize() = 0; + // A texture in table slot `slot`, mips largest first: RGBA8 pixels, or BC7 blocks (YAPF 2's + // GPU payload) when blockCompression() says the GPU takes them. + virtual void createTexture(TextureId slot, uint32_t width, uint32_t height, bool srgb, const std::vector>& mips, + TexFormat format = TexFormat::RGBA8) = 0; + virtual bool blockCompression() const { return false; } + // Can draw the scene smaller than the window and scale it up (r_renderscale, r_dynres). + virtual bool renderScale() const { return false; } + // Model GPU data in slot `model`: created, or replaced (the old data lives until no frame uses it). + virtual void uploadModel(uint32_t model, const KmdlModel& kmdl) = 0; + // A texture as Dear ImGui's ImTextureID, for drawing it in the UI (0 = none). + virtual uint64_t uiTexture(TextureId slot) = 0; + // Starts a frame; false when nothing can be drawn (minimised). Sets the drawable size. + virtual bool beginFrame(uint32_t& width, uint32_t& height) = 0; + // Renders the packet, then ImGui's draw data, and presents. + virtual void render(FramePacket& packet) = 0; + // The newest occlusion grid that has come back from the GPU (a frame or two old); false if + // none newer than the last one taken. + virtual bool takeHzb(HzbReadback& out) = 0; + // How long each pass took on the GPU, a frame or two ago (r_profile); empty if not measured. + virtual void gpuTimes(std::vector>& out) const { out.clear(); } + // GPU occlusion culling: supported, and how many draws it hid (a frame or two ago). + virtual bool gpuCulling() const { return false; } + virtual bool froxelFog() const { return false; } // the fog grid (FrameData.froxel) + virtual bool rayTracing() const { return false; } // hardware ray queries: real-time GI + virtual bool mirrors() const { return false; } // a second scene pass for a mirror (FramePacket::mirror) + virtual uint32_t gpuCulled() const { return 0; } +}; + +std::unique_ptr createVulkanBackend(); +std::unique_ptr createOpenGLBackend(); +std::unique_ptr createLegacyOpenGLBackend(int major, int minor); + +} // namespace keel diff --git a/engine/render/clusters.cpp b/engine/render/clusters.cpp new file mode 100644 index 0000000..0bb8d92 --- /dev/null +++ b/engine/render/clusters.cpp @@ -0,0 +1,72 @@ +#include "render/clusters.h" + +#include +#include + +namespace keel { +namespace { +const float kLogRatio = std::log(kClusterFar / kClusterNear); + +float sliceStart(uint32_t k) { return k == 0 ? 0.0f : kClusterNear * std::exp(kLogRatio * float(k) / kClusterZ); } + +float distSq(float v, float lo, float hi) { + const float d = v < lo ? lo - v : v > hi ? v - hi : 0.0f; + return d * d; +} +} // namespace + +uint32_t clusterSlice(float depth) { + if (depth <= kClusterNear) return 0; + return std::min(uint32_t(std::log(depth / kClusterNear) / kLogRatio * kClusterZ), kClusterZ - 1); +} + +void buildClusters(const ClusterView& v, const std::vector& lights, size_t maxIndices, ClusterGrid& out) { + const uint32_t cellCount = kClusterX * kClusterY * kClusterZ; + out.cells.assign(cellCount, glm::uvec2(0)); + out.indices.clear(); + const float tanY = v.tanHalfY, tanX = v.tanHalfY * v.aspect; + std::vector pairs; // cell << 12 | light + for (uint32_t li = 0; li < lights.size() && li < 4096; ++li) { + const DynamicLight& l = lights[li]; + const glm::vec3 d = l.origin - v.origin; + const glm::vec3 c(glm::dot(d, v.right), glm::dot(d, v.up), glm::dot(d, v.forward)); // view space, z forward + const float r = l.radius; + if (c.z + r <= 0.0f) continue; + const uint32_t z0 = clusterSlice(std::max(c.z - r, 0.0f)), z1 = clusterSlice(c.z + r); + for (uint32_t z = z0; z <= z1; ++z) { + const float dn = std::max(sliceStart(z), 0.05f), df = z + 1 == kClusterZ ? kClusterFar : sliceStart(z + 1); + // Conservative tile range from the sphere's extent at both ends of the slice. + auto tileU = [&](float x, float depth) { return (x / (depth * tanX) + 1.0f) * 0.5f * kClusterX; }; + auto tileV = [&](float y, float depth) { return (1.0f - y / (depth * tanY)) * 0.5f * kClusterY; }; + float u0 = 1e9f, u1 = -1e9f, v0 = 1e9f, v1 = -1e9f; + for (float depth : {dn, df}) { + for (float x : {c.x - r, c.x + r}) u0 = std::min(u0, tileU(x, depth)), u1 = std::max(u1, tileU(x, depth)); + for (float y : {c.y - r, c.y + r}) v0 = std::min(v0, tileV(y, depth)), v1 = std::max(v1, tileV(y, depth)); + } + const int x0 = std::max(int(std::floor(u0)), 0), x1 = std::min(int(std::floor(u1)), int(kClusterX) - 1); + const int y0 = std::max(int(std::floor(v0)), 0), y1 = std::min(int(std::floor(v1)), int(kClusterY) - 1); + for (int y = y0; y <= y1; ++y) { + // Cell bounds in view space: y shrinks downwards (v grows), x grows rightwards. + const float yt = 1.0f - 2.0f * y / kClusterY, yb = 1.0f - 2.0f * (y + 1) / kClusterY; + const float ylo = std::min({yb * dn, yb * df}) * tanY, yhi = std::max({yt * dn, yt * df}) * tanY; + for (int x = x0; x <= x1; ++x) { + const float xl = 2.0f * x / kClusterX - 1.0f, xr = 2.0f * (x + 1) / kClusterX - 1.0f; + const float xlo = std::min({xl * dn, xl * df}) * tanX, xhi = std::max({xr * dn, xr * df}) * tanX; + if (distSq(c.x, xlo, xhi) + distSq(c.y, ylo, yhi) + distSq(c.z, dn, df) > r * r) continue; + pairs.push_back(((z * kClusterY + y) * kClusterX + x) << 12 | li); + } + } + } + } + std::sort(pairs.begin(), pairs.end()); + if (pairs.size() > maxIndices) pairs.resize(maxIndices); + out.indices.reserve(pairs.size()); + for (uint32_t p : pairs) { + glm::uvec2& cell = out.cells[p >> 12]; + if (cell.y == 0) cell.x = uint32_t(out.indices.size()); + ++cell.y; + out.indices.push_back(p & 0xFFF); + } +} + +} // namespace keel diff --git a/engine/render/clusters.h b/engine/render/clusters.h new file mode 100644 index 0000000..c355c2f --- /dev/null +++ b/engine/render/clusters.h @@ -0,0 +1,45 @@ +#pragma once +// Clustered lighting: the view frustum is cut into a 3D grid (screen tiles × depth +// slices) and each cell lists the lights that can reach it, so a pixel only shades +// the few lights near it. Built on the CPU each frame; no GPU code here, so it is +// shared by every render backend and testable headless. +#include + +#include +#include + +namespace keel { + +struct DynamicLight { + glm::vec3 origin{0.0f}; + float radius = 300.0f; // light reaches zero here + glm::vec3 color{1.0f}; // linear, already scaled by intensity + glm::vec3 direction{0, 0, -1}; // spot lights + float innerCone = 0.0f; // spot half-angles in degrees; outerCone 0 = point light + float outerCone = 0.0f; + bool shadows = false; + float size = 0.0f; // the light's own radius (a bulb, a lamp, a window): softer, wider highlights + bool reflector = false; // a flashlight's beam (spotShape in model.glsl): hot centre, a ring, a dim spill + bool spot() const { return outerCone > 0.0f; } +}; + +// Must match lights.glsl. +inline constexpr uint32_t kClusterX = 16, kClusterY = 9, kClusterZ = 24; +inline constexpr float kClusterNear = 8.0f, kClusterFar = 16384.0f; + +struct ClusterView { + glm::vec3 origin, forward, right, up; // up is the screen's up (after roll) + float tanHalfY = 1.0f, aspect = 1.0f; +}; + +struct ClusterGrid { + std::vector cells; // per cell: first index, light count; cell = (z * Y + y) * X + x + std::vector indices; // light indices, grouped by cell +}; + +// Depth slice for a distance along the view direction. +uint32_t clusterSlice(float depth); +// Lights past maxIndices total cell entries are dropped (farthest cells last). +void buildClusters(const ClusterView& view, const std::vector& lights, size_t maxIndices, ClusterGrid& out); + +} // namespace keel diff --git a/engine/render/renderer.cpp b/engine/render/renderer.cpp new file mode 100644 index 0000000..9d3816c --- /dev/null +++ b/engine/render/renderer.cpp @@ -0,0 +1,804 @@ +// The renderer's API-independent half: collects draws, lights and the camera each frame, +// works out shadow cascades, light clusters, culling and sorting, and hands the result to +// the backend (Vulkan or OpenGL) as a FramePacket. +#include +#include "render/renderer.h" +#include "core/time.h" +#include "asset/ksky.h" +#include "core/cvar.h" +#include "fs/vfs.h" +#include "core/log.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace keel { +namespace { +// Planes (xyz·p + w ≥ 0 inside) of a view-projection's frustum. `sides` only: no near/far +// (sun cascades keep casters between the sun and the slice they shadow). +struct Frustum { + glm::vec4 planes[6]; + int count = 0; + Frustum(const glm::mat4& m, bool sidesOnly) { + const glm::mat4 t = glm::transpose(m); + planes[count++] = t[3] + t[0]; + planes[count++] = t[3] - t[0]; + planes[count++] = t[3] + t[1]; + planes[count++] = t[3] - t[1]; + if (!sidesOnly) planes[count++] = t[2]; // near (depth 0..1); no far: the sky is there + } + // The world box of a transformed model-space box, tested against every plane. + bool sees(const glm::mat4& xf, const glm::vec3& lo, const glm::vec3& hi) const { + const glm::vec3 c = glm::vec3(xf * glm::vec4((lo + hi) * 0.5f, 1.0f)); + const glm::vec3 h = (hi - lo) * 0.5f; + const glm::mat3 a(glm::abs(glm::vec3(xf[0])), glm::abs(glm::vec3(xf[1])), glm::abs(glm::vec3(xf[2]))); + const glm::vec3 e = a * h; + for (int i = 0; i < count; ++i) { + const glm::vec3 n(planes[i]); + if (glm::dot(n, c) + planes[i].w + glm::dot(glm::abs(n), e) < 0.0f) return false; + } + return true; + } +}; + +Cvar r_vsync("r_vsync", "1", CVAR_ARCHIVE, "Wait for vertical sync (0 = uncapped)"); +Cvar r_shadowres("r_shadowres", "4096", CVAR_ARCHIVE, "Sun shadow map size: 2048, 4096 or 8192 (2×2 cascades)"); +Cvar r_cull("r_cull", "1", CVAR_NONE, "Skip what the camera (or a shadow) can't see"); +Cvar r_occlusion("r_occlusion", "1", CVAR_ARCHIVE, "Skip what's hidden behind other things (hierarchical Z of a frame ago)"); +Cvar r_skyintensity("r_skyintensity", "1", CVAR_ARCHIVE, "Sky brightness (times the map's)"); +Cvar r_fog("r_fog", "1", CVAR_ARCHIVE, "Height fog / aerial perspective"); +Cvar r_gi("r_gi", "1", CVAR_ARCHIVE, "Real-time GI (Vulkan, ray-tracing GPUs): light from flashlights, fires and lamps bounces off what's around (2: show only that)"); +Cvar r_mirrors("r_mirrors", "1", CVAR_ARCHIVE, "Mirrors show the room (the nearest one in view; Vulkan)"); +Cvar r_volumetricfog("r_volumetricfog", "1", CVAR_ARCHIVE, "Fog in a 3D grid (Vulkan): lit by the sun, its shadows and the lamps"); +Cvar r_fogscale("r_fogscale", "1", CVAR_CHEAT, "Multiplies the map's fog density (testing)"); +Cvar r_skycolor("r_skycolor", "0.45 0.6 0.8", CVAR_ARCHIVE, "Sky colour, \"r g b\" in sRGB"); +Cvar r_exposure("r_exposure", "1.4", CVAR_ARCHIVE, "Scene brightness before tonemapping"); +Cvar r_ssao("r_ssao", "1", CVAR_ARCHIVE, "Ambient occlusion: soft shadow in corners and under things (0 off .. 1 full)"); +Cvar r_ssao_radius("r_ssao_radius", "24", CVAR_ARCHIVE, "Ambient occlusion reach (units)"); +Cvar r_bloom("r_bloom", "0.035", CVAR_ARCHIVE, "Bloom: how much bright light glows over its surroundings (0 off)"); +Cvar r_saturation("r_saturation", "1.05", CVAR_ARCHIVE, "Colour saturation after tonemapping"); +Cvar r_taa("r_taa", "1", CVAR_ARCHIVE, "Temporal anti-aliasing: smooth edges using the last frames"); +Cvar r_sharpen("r_sharpen", "0.3", CVAR_ARCHIVE, "Sharpening after TAA (0 off)"); +Cvar r_shadowsoft("r_shadowsoft", "1", CVAR_ARCHIVE, "Soft sun shadows (PCSS): sharp where they touch, softer further away; the sun's size (0 = a fixed small blur)"); +Cvar r_volumetric("r_volumetric", "0", CVAR_ARCHIVE, "Volumetric sunlight: shafts of sun in the air, shadowed by what's in the way (0 off)"); +Cvar r_autoexposure("r_autoexposure", "1", CVAR_ARCHIVE, "Eye adaptation: brighter in the dark, darker in the sun, over a second or two (0 off)"); +Cvar r_renderscale("r_renderscale", "1", CVAR_ARCHIVE, "The 3D scene's resolution as a fraction of the window's (0.5 to 1; TAA scales it up)"); +Cvar r_dynres("r_dynres", "0", CVAR_ARCHIVE, "Dynamic resolution: lower the render scale as needed to hold this frame rate (0 off)"); +Cvar r_gpuculling("r_gpuculling", "1", CVAR_ARCHIVE, "Occlusion culling on the GPU (Vulkan): hidden things aren't drawn, tested a frame sooner than on the CPU"); +Cvar r_prepass("r_prepass", "1", CVAR_ARCHIVE, "Depth pre-pass: the scene's depth first, so each pixel is lit once (faster when things overlap)"); +Cvar r_ssr("r_ssr", "0", CVAR_ARCHIVE, "Screen-space reflections: what's on screen mirrored in glossy floors, metal and water (0 off)"); +Cvar r_shafts("r_shafts", "0.5", CVAR_ARCHIVE, "Sun shafts: light scattering in the air around the sun where the sky shows through (0 off)"); +Cvar r_vignette("r_vignette", "0.25", CVAR_ARCHIVE, "Darkening towards the screen's corners"); +Cvar r_dynamic("r_dynamic", "1", CVAR_ARCHIVE, "Real-time lights (flashlights, map light_dynamic/light_spot, scripts)"); +Cvar r_maxlights("r_maxlights", "256", CVAR_ARCHIVE, "Most dynamic lights drawn per frame (nearest win)"); +Cvar r_lightshadows("r_lightshadows", "4", CVAR_ARCHIVE, "Most dynamic lights casting shadows per frame (0 = none)"); +Cvar r_lightshadowres("r_lightshadowres", "512", CVAR_ARCHIVE, "Shadow resolution per light face: 256, 512, 1024 or 2048"); +Cvar r_lightdebug("r_lightdebug", "0", 0, "1 = tint the scene by how many lights touch each cluster"); +Cvar r_shadowdist("r_shadowdist", "4000", CVAR_ARCHIVE, "How far sun shadows reach (units)"); +Cvar r_drawlights("r_drawlights", "0", 0, "Draw a small box at every dynamic light"); + +float srgbToLinear(float c) { return c <= 0.04045f ? c / 12.92f : std::pow((c + 0.055f) / 1.055f, 2.4f); } + +uint32_t packColor(const glm::vec4& c) { + auto b = [](float v) { return uint32_t(std::lround(std::clamp(v, 0.0f, 1.0f) * 255.0f)); }; + return b(c.r) | b(c.g) << 8 | b(c.b) << 16 | b(c.a) << 24; +} + +glm::vec3 forwardOf(const Camera& c) { + const float yaw = glm::radians(c.yaw), pitch = glm::radians(c.pitch); + return {std::cos(pitch) * std::cos(yaw), std::cos(pitch) * std::sin(yaw), -std::sin(pitch)}; +} + +glm::mat4 viewProjection(const Camera& c, float aspect) { + const glm::vec3 forward = forwardOf(c); + const glm::vec3 up = glm::vec3(glm::rotate(glm::mat4(1), glm::radians(c.roll), forward) * glm::vec4(0, 0, 1, 0)); + glm::mat4 view = glm::lookAt(c.origin, c.origin + forward, up); + glm::mat4 proj = glm::perspective(glm::radians(c.fovY), aspect, 2.0f, 16384.0f); + proj[1][1] *= -1.0f; // clip-space Y points down (Vulkan; OpenGL is set up to match) + proj[2][0] -= c.shift.x, proj[2][1] -= c.shift.y; + return proj * view; +} +} // namespace + +glm::mat4 Renderer::cameraViewProjection(float aspect) const { return viewProjection(camera_, aspect); } + +bool Renderer::init(const Window& window, bool validation, RenderApi api) { + switch (api) { + case RenderApi::OpenGL: backend_ = createOpenGLBackend(); break; + case RenderApi::OpenGL40: backend_ = createLegacyOpenGLBackend(4, 0); break; + case RenderApi::OpenGL31: backend_ = createLegacyOpenGLBackend(3, 1); break; + default: backend_ = createVulkanBackend(); break; + } + if (!backend_->init(window, validation)) { + backend_->shutdown(); + backend_.reset(); + return false; + } + log::info("Renderer: {}", backend_->name()); + // Fixed slots: 0 white, 1 a "missing" checker. (2-4 are the backend's render targets.) + const std::vector white(4, 255); + backend_->createTexture(kWhiteTexture, 1, 1, false, {white}); + std::vector missing(64 * 64 * 4); + for (int y = 0; y < 64; ++y) + for (int x = 0; x < 64; ++x) { + const bool on = ((x / 8) ^ (y / 8)) & 1; + uint8_t* p = &missing[(y * 64 + x) * 4]; + p[0] = on ? 255 : 0; p[1] = 0; p[2] = on ? 255 : 0; p[3] = 255; + } + backend_->createTexture(kMissingTexture, 64, 64, false, {missing}); + return true; +} + +void Renderer::shutdown() { + if (backend_) backend_->shutdown(); + backend_.reset(); + models_.clear(); + modelCache_.clear(); + textureCache_.clear(); + nextTexture_ = kFirstTextureSlot; +} + +void Renderer::beginUi() { backend_->beginUi(); } + +void Renderer::drawBox(const glm::vec3& mins, const glm::vec3& maxs, const glm::vec4& color, TextureId texture) { + packet_.boxes.push_back({glm::vec4((mins + maxs) * 0.5f, float(texture)), glm::vec4((maxs - mins) * 0.5f, 0), color}); +} + +const Renderer::Sky* Renderer::loadSky(const std::string& path) { + if (auto it = skies_.find(path); it != skies_.end()) return &it->second; + const auto data = fs::read(path); + KskyHeader h{}; + if (!data || data->size() < sizeof h) { + log::warn("Sky {} not found", path); + return nullptr; + } + std::memcpy(&h, data->data(), sizeof h); + if (std::memcmp(h.magic, "KSKY", 4) != 0 || h.version != kKskyVersion) { + log::warn("{}: not a sky (version {})", path, h.version); + return nullptr; + } + std::vector> mips; + size_t at = sizeof h; + for (uint32_t k = 0; k < h.levels; ++k) { + const size_t bytes = size_t(h.width >> k) * size_t(h.height >> k) * 4; + if (at + bytes > data->size()) return nullptr; + mips.emplace_back(data->begin() + ptrdiff_t(at), data->begin() + ptrdiff_t(at + bytes)); + at += bytes; + } + Sky sky; + sky.texture = newTexture(h.width, h.height, false, mips); + sky.levels = h.levels; + sky.sunDir = glm::vec3(h.sunDir[0], h.sunDir[1], h.sunDir[2]); + for (int i = 0; i < 9; ++i) sky.sh[i] = glm::vec3(h.sh[i * 3], h.sh[i * 3 + 1], h.sh[i * 3 + 2]); + return &(skies_[path] = sky); +} + +void Renderer::setAtmosphere(const Atmosphere& a) { + atmosphere_ = a; + sky_ = {}; + if (const Sky* s = a.sky.empty() ? nullptr : loadSky(a.sky)) sky_ = *s; +} + +void Renderer::setProbes(const std::vector>& probes) { + if (probes == probeList_) return; + probeList_ = probes; + probes_.clear(); + for (const auto& [at, path] : probes) + if (const Sky* s = loadSky(path)) probes_.push_back({at, s->texture, float(s->levels - 1)}); +} + +void Renderer::setSun(bool enabled, const glm::vec3& dir, const glm::vec3& color, bool realtime) { + sunEnabled_ = enabled; + sunRealtime_ = enabled && realtime; + sunDir_ = glm::normalize(dir); + sunColor_ = color; +} + +void Renderer::drawSprite(const glm::vec3& at, float size, const glm::vec4& color, TextureId texture, float rotation, bool additive) { + auto& list = additive ? packet_.glowSprites : packet_.sprites; + if (list.size() >= 8192) return; + uint32_t bits = texture; + float tex; + std::memcpy(&tex, &bits, 4); + list.push_back({glm::vec4(at, size * 0.5f), color, glm::vec4(0, 0, 0, rotation), glm::vec4(0, 0, 0, tex)}); +} + +void Renderer::drawStreak(const glm::vec3& from, const glm::vec3& to, float width, const glm::vec4& color, TextureId texture, bool additive) { + auto& list = additive ? packet_.glowSprites : packet_.sprites; + if (list.size() >= 8192) return; + uint32_t bits = texture; + float tex; + std::memcpy(&tex, &bits, 4); + // up.xyz: half its length along it (non-zero marks a streak; its side is found per frame). + list.push_back({glm::vec4((from + to) * 0.5f, width * 0.5f), color, glm::vec4(0.0f), glm::vec4((to - from) * 0.5f, tex)}); +} + +void Renderer::drawModel(ModelId model, const glm::mat4& transform, const glm::vec4& tint, const std::vector* skin, + const ModelLighting* lighting, bool castShadow, const glm::vec4* liquidPlane, LiquidStyle liquid) { + if (!model.valid() || packet_.draws.size() >= kMaxDraws) return; + ModelDraw d{model.index, transform, tint}; + d.castShadow = castShadow || shadowOnly_; + d.shadowOnly = shadowOnly_; + d.viewModel = viewModel_; + d.texture = textureOverride_; + d.liquid = liquid != LiquidStyle::None; + const Model& mdl = models_[model.index]; + d.boundsMin = glm::vec3(1e30f); + d.boundsMax = glm::vec3(-1e30f); + for (int i = 0; i < 8; ++i) { + const glm::vec3 corner(i & 1 ? mdl.maxs.x : mdl.mins.x, i & 2 ? mdl.maxs.y : mdl.mins.y, i & 4 ? mdl.maxs.z : mdl.mins.z); + const glm::vec3 w = glm::vec3(transform * glm::vec4(corner, 1.0f)); + d.boundsMin = glm::min(d.boundsMin, w); + d.boundsMax = glm::max(d.boundsMax, w); + } + if (skin) d.boundsMin -= glm::vec3(32.0f), d.boundsMax += glm::vec3(32.0f); // animation leaves the bind pose + d.drawIndex = uint32_t(packet_.draws.size()); + gpu::DrawData dd{}; + dd.model = transform; + if (lighting) { + for (int i = 0; i < 6; ++i) dd.cube[i] = glm::vec4(lighting->cube[i], 0); + dd.params = glm::vec4(lighting->sunVisibility, 1, 0, 0); + } + if (liquidPlane) { + dd.params.z = 1.0f; + dd.plane = *liquidPlane; + } + dd.params.w = float(int(liquid)); + if (!probes_.empty()) { // the nearest reflection probe to the middle of it + const glm::vec3 mid = (d.boundsMin + d.boundsMax) * 0.5f; + const Probe* best = &probes_[0]; + for (const Probe& pr : probes_) + if (glm::distance(pr.at, mid) < glm::distance(best->at, mid)) best = ≺ + dd.probe = glm::vec4(float(best->texture), best->top, 0, 0); + } + static const auto t0 = std::chrono::steady_clock::now(); // water's waves move with this + dd.probe.z = hasClock_ ? float(std::fmod(clock_, 3600.0)) : std::fmod(std::chrono::duration(std::chrono::steady_clock::now() - t0).count(), 3600.0f); + packet_.draws.push_back(dd); + if (skin && mdl.skinned && packet_.joints.size() + skin->size() <= kMaxJointsPerFrame) { + d.firstJoint = uint32_t(packet_.joints.size()); + for (const glm::mat4& m : *skin) packet_.joints.push_back(transform * m); + } + modelDraws_.push_back(d); +} + +void Renderer::clearFrame() { + modelDraws_.clear(); + packet_.smoke.clear(); + lights_.clear(); + packet_.draws.clear(); + packet_.joints.clear(); + packet_.boxes.clear(); + packet_.sprites.clear(); + packet_.glowSprites.clear(); +} + +void Renderer::drawFrame() { + ImGui::Render(); + if (r_vsync.consumeModified()) backend_->setVsync(r_vsync.b()); + uint32_t width = 0, height = 0; + using clock = std::chrono::steady_clock; + auto ms = [](clock::time_point a, clock::time_point b) { return std::chrono::duration(b - a).count(); }; + const auto t0 = clock::now(); + if (!backend_->beginFrame(width, height)) return clearFrame(); + const auto t1 = clock::now(); + buildPacket(width, height); + const auto t2 = clock::now(); + backend_->render(packet_); + const auto t3 = clock::now(); + timings_.wait = ms(t0, t1), timings_.build = ms(t1, t2), timings_.record = ms(t2, t3); + backend_->gpuTimes(timings_.gpu); + screenshotPath_.clear(); + clearFrame(); +} + +void Renderer::takeOcclusion() { + HzbReadback back; + if (!backend_->takeHzb(back) || back.depth.size() != size_t(kHzbWidth) * kHzbHeight) return; + auto it = std::find_if(hzbPending_.begin(), hzbPending_.end(), [&](const auto& f) { return f.first == back.tag; }); + if (it == hzbPending_.end()) return; + hzbFrame_ = it->second; + std::erase_if(hzbPending_, [&](const auto& f) { return f.first <= back.tag; }); + // The pyramid: each level the farthest of the 2×2 below it (odd edges keep their last row). + hzbLevels_.clear(); + hzbLevels_.push_back(std::move(back.depth)); + uint32_t w = kHzbWidth, h = kHzbHeight; + while (w > 1 || h > 1) { + const uint32_t nw = std::max(1u, (w + 1) / 2), nh = std::max(1u, (h + 1) / 2); + const std::vector& src = hzbLevels_.back(); + std::vector dst(size_t(nw) * nh); + for (uint32_t y = 0; y < nh; ++y) + for (uint32_t x = 0; x < nw; ++x) { + float m = 0.0f; + for (uint32_t dy = 0; dy < 2; ++dy) + for (uint32_t dx = 0; dx < 2; ++dx) + m = std::max(m, src[size_t(std::min(2 * y + dy, h - 1)) * w + std::min(2 * x + dx, w - 1)]); + dst[size_t(y) * nw + x] = m; + } + hzbLevels_.push_back(std::move(dst)); + w = nw, h = nh; + } + hzbValid_ = true; +} + +// Hidden: the box, seen from the grid's camera, is farther than everything already drawn over +// the whole patch of screen it covers. Anything touching the near plane or the screen's edge +// counts as seen. +bool Renderer::occluded(const glm::mat4& xf, const glm::vec3& lo, const glm::vec3& hi) const { + glm::vec2 a(1e9f), b(-1e9f); + float nearest = 1.0f; + for (int i = 0; i < 8; ++i) { + const glm::vec3 corner(i & 1 ? hi.x : lo.x, i & 2 ? hi.y : lo.y, i & 4 ? hi.z : lo.z); + const glm::vec4 c = hzbFrame_.viewProj * (xf * glm::vec4(corner, 1.0f)); + if (c.w < 2.0f) return false; // at or behind the camera + const glm::vec3 ndc = glm::vec3(c) / c.w; + a = glm::min(a, glm::vec2(ndc)), b = glm::max(b, glm::vec2(ndc)); + nearest = std::min(nearest, ndc.z); + } + if (a.x < -1.0f || a.y < -1.0f || b.x > 1.0f || b.y > 1.0f) return false; + // Texels covered at level 0, one extra round (the camera moved a little since). + int x0 = int((a.x * 0.5f + 0.5f) * kHzbWidth) - 1, x1 = int((b.x * 0.5f + 0.5f) * kHzbWidth) + 1; + int y0 = int((a.y * 0.5f + 0.5f) * kHzbHeight) - 1, y1 = int((b.y * 0.5f + 0.5f) * kHzbHeight) + 1; + size_t level = 0; + while (level + 1 < hzbLevels_.size() && (x1 - x0 > 3 || y1 - y0 > 3)) x0 >>= 1, x1 >>= 1, y0 >>= 1, y1 >>= 1, ++level; + const int w = std::max(1, int(kHzbWidth >> level)), h = std::max(1, int(kHzbHeight >> level)); + const std::vector& d = hzbLevels_[level]; + for (int y = std::max(y0, 0); y <= std::min(y1, h - 1); ++y) + for (int x = std::max(x0, 0); x <= std::min(x1, w - 1); ++x) + if (d[size_t(y) * size_t(w) + size_t(x)] >= nearest) return false; + return true; +} + +void Renderer::buildPacket(uint32_t width, uint32_t height) { + FramePacket& p = packet_; + p.outWidth = width; + p.outHeight = height; + // Render scale: the 3D scene at a fraction of the window (TAA and the tonemap scale it up). + // r_dynres moves it to hold that frame rate, a step at a time with a pause between. + float scale = 1.0f; + if (backend_->renderScale()) { + scale = std::clamp(r_renderscale.f(), 0.5f, 1.0f); + if (r_dynres.f() > 0.0f) { + static double last = keel::now(), lastChange = 0; + const double t = keel::now(), frame = t - last; + last = t; + dynFrame_ = dynFrame_ * 0.9 + frame * 0.1; // smoothed + const double target = 1.0 / r_dynres.f(); + if (t - lastChange > 0.5) { + if (dynFrame_ > target * 1.08 && dynScale_ > 0.5f) dynScale_ = std::max(0.5f, dynScale_ - 0.05f), lastChange = t; + else if (dynFrame_ < target * 0.8 && dynScale_ < scale) dynScale_ = std::min(scale, dynScale_ + 0.05f), lastChange = t; + } + scale = std::min(scale, dynScale_); + } else { + dynScale_ = scale; + } + } + width = std::max(1u, uint32_t(std::lround(float(width) * scale))); + height = std::max(1u, uint32_t(std::lround(float(height) * scale))); + p.width = width; + p.height = height; + p.viewProj = viewProjection(camera_, float(width) / float(height)); + p.shadowSize = r_shadowres.i() >= 8192 ? 8192u : r_shadowres.i() <= 2048 ? 2048u : 4096u; + // TAA: a different sub-pixel offset each frame (Halton 2,3), and how to find last frame's + // pixels. History is dropped on resize, toggling, or a jump (teleport, respawn). + { + const glm::mat4 unjittered = p.viewProj; + const bool taa = r_taa.b(); + p.post.taa = taa; + p.post.taaReset = !taa || !taaPrevValid_ || width != taaPrevW_ || height != taaPrevH_ || glm::length(camera_.origin - taaPrevOrigin_) > 128.0f; + p.post.reproject = taaPrevViewProj_ * glm::inverse(unjittered); + if (taa) { + auto halton = [](uint32_t i, uint32_t b) { + float f = 1.0f, r = 0.0f; + for (; i > 0; i /= b) f /= float(b), r += f * float(i % b); + return r; + }; + const uint32_t i = (taaFrame_++ % 8) + 1; + const glm::vec2 jitter((halton(i, 2) - 0.5f) * 2.0f / float(width), (halton(i, 3) - 0.5f) * 2.0f / float(height)); + glm::mat4 shift(1.0f); // clip space: x' = x + jx·w, y' = y + jy·w + shift[3][0] = jitter.x; + shift[3][1] = jitter.y; + p.viewProj = shift * unjittered; + } + taaPrevViewProj_ = unjittered; + taaPrevValid_ = taa; + taaPrevW_ = width, taaPrevH_ = height; + taaPrevOrigin_ = camera_.origin; + } + p.screenshot = screenshotPath_; + + // ── Sun cascades: each fits a slice of the view frustum in a bounding sphere (so its size + // never changes as the camera turns) and snaps to whole texels (so edges don't shimmer). + gpu::FrameData& frame = p.frame; + frame = {}; + const glm::vec3 camFwd = forwardOf(camera_); + { + const float maxDist = std::clamp(r_shadowdist.f(), 500.0f, 16000.0f); + // "Practical" splits (Zhang et al.): mostly logarithmic, so the nearest cascade stays small + // (sharp) however far the shadows reach; the far ones take the distance. + float splits[4]; + for (int c = 0; c < 4; ++c) { + const float k = float(c + 1) / 4.0f, n = 8.0f; + splits[c] = 0.92f * n * std::pow(maxDist / n, k) + 0.08f * (n + (maxDist - n) * k); + } + splits[3] = maxDist; + const glm::vec3 right = glm::normalize(glm::cross(camFwd, glm::vec3(0, 0, 1) + glm::vec3(0.0001f, 0, 0))), upv = glm::cross(right, camFwd); + const float tanY = std::tan(glm::radians(camera_.fovY) * 0.5f), tanX = tanY * float(width) / float(height); + const glm::vec3 up = std::abs(sunDir_.z) > 0.95f ? glm::vec3(1, 0, 0) : glm::vec3(0, 0, 1); + const float cascadeSize = float(p.shadowSize) / 2.0f; + for (int c = 0; c < 4; ++c) { + const float n = c == 0 ? 2.0f : splits[c - 1], f = splits[c]; + glm::vec3 corners[8], center(0.0f); + for (int k = 0; k < 8; ++k) { + const float d = k < 4 ? n : f; + corners[k] = camera_.origin + camFwd * d + right * (k & 1 ? tanX : -tanX) * d + upv * (k & 2 ? tanY : -tanY) * d; + center += corners[k] / 8.0f; + } + float radius = 0.0f; + for (const glm::vec3& q : corners) radius = std::max(radius, glm::length(q - center)); + radius = std::ceil(radius / 16.0f) * 16.0f; + const float texel = 2.0f * radius / cascadeSize; + glm::mat4 lightView = glm::lookAt(glm::vec3(0), -sunDir_, up); + glm::vec4 lc = lightView * glm::vec4(center, 1); + lc.x = std::floor(lc.x / texel) * texel; + lc.y = std::floor(lc.y / texel) * texel; + center = glm::vec3(glm::inverse(lightView) * lc); + lightView = glm::lookAt(center + sunDir_ * 8000.0f, center, up); + frame.cascades[c] = glm::ortho(-radius, radius, -radius, radius, 1.0f, 16000.0f) * lightView; + frame.splits[c] = f; + frame.cascadeTexel[c] = texel; + } + } + frame.sunDir = glm::vec4(sunDir_, float(kSunShadowSlot)); + frame.sunColor = glm::vec4(sunColor_, sunEnabled_ ? 1.0f : 0.0f); + frame.shadow = glm::vec4(1.0f / float(p.shadowSize), 0.55f, sunRealtime_ ? 1.0f : 0.0f, std::max(r_shadowsoft.f(), 0.0f)); + // Strength, reach, air density; w: the sun takes part (a real-time sun; lamps always do). + frame.volumetric = glm::vec4(std::max(r_volumetric.f(), 0.0f), 2500.0f, 0.00015f, sunRealtime_ ? 1.0f : 0.0f); + + + // ── Dynamic lights: cull, pick shadow casters, cluster ── + const glm::vec3 camUp0 = glm::vec3(glm::rotate(glm::mat4(1), glm::radians(camera_.roll), camFwd) * glm::vec4(0, 0, 1, 0)); + const glm::vec3 camRight = glm::normalize(glm::cross(camFwd, camUp0)), camUp = glm::cross(camRight, camFwd); + if (!r_dynamic.b()) lights_.clear(); + std::erase_if(lights_, [&](const DynamicLight& l) { return l.radius <= 0 || glm::dot(l.origin - camera_.origin, camFwd) < -l.radius; }); + auto nearness = [&](const DynamicLight& l) { return glm::length(l.origin - camera_.origin) - l.radius; }; + std::stable_sort(lights_.begin(), lights_.end(), [&](const DynamicLight& a, const DynamicLight& b) { return nearness(a) < nearness(b); }); + lights_.resize(std::min(lights_.size(), std::min(std::max(r_maxlights.i(), 0), kMaxLights))); + const uint32_t tile = std::clamp(uint32_t(r_lightshadowres.i()), 128u, 2048u); + const uint32_t tilesPerRow = kAtlasSize / tile, tileBudget = std::min(tilesPerRow * tilesPerRow, kMaxShadowViews); + p.lights.assign(lights_.size(), {}); + p.views.clear(); + struct ViewSphere { glm::vec3 center; float radius; }; + std::vector viewSpheres; + int shadowLights = 0; + for (size_t i = 0; i < lights_.size(); ++i) { + const DynamicLight& l = lights_[i]; + gpu::Light& g = p.lights[i]; + g.posRadius = glm::vec4(l.origin, l.radius); + const glm::vec3 dir = glm::length(l.direction) > 0 ? glm::normalize(l.direction) : glm::vec3(0, 0, -1); + const float outer = std::clamp(l.outerCone, 1.0f, 89.0f), inner = std::clamp(l.innerCone, 0.0f, outer - 0.5f); + g.spot = glm::vec4(dir, l.spot() ? std::cos(glm::radians(outer)) : -2.0f); + g.extra = glm::vec4(std::cos(glm::radians(inner)), std::max(l.size, 0.0f), l.reflector && l.spot() ? 1.0f : 0.0f, 0); + g.color = glm::vec4(l.color, -1.0f); + const uint32_t need = l.spot() ? 1 : 6; + if (!l.shadows || shadowLights >= r_lightshadows.i() || p.views.size() + need > tileBudget) continue; + ++shadowLights; + g.color.w = float(p.views.size()); + auto addView = [&](const glm::vec3& fwd, float fovDeg) { + const glm::vec3 up = std::abs(fwd.z) > 0.99f ? glm::vec3(1, 0, 0) : glm::vec3(0, 0, 1); + const uint32_t t = uint32_t(p.views.size()); + const glm::vec4 rect(float(t % tilesPerRow * tile) / kAtlasSize, float(t / tilesPerRow * tile) / kAtlasSize, float(tile) / kAtlasSize, + float(tile) / kAtlasSize); + p.views.push_back({glm::perspective(glm::radians(fovDeg), 1.0f, 1.0f, l.radius) * glm::lookAt(l.origin, l.origin + fwd, up), rect}); + viewSpheres.push_back({l.origin, l.radius}); + }; + if (l.spot()) addView(dir, std::min(2.0f * outer + 4.0f, 170.0f)); + else + for (const glm::vec3& f : {glm::vec3(1, 0, 0), glm::vec3(-1, 0, 0), glm::vec3(0, 1, 0), glm::vec3(0, -1, 0), glm::vec3(0, 0, 1), glm::vec3(0, 0, -1)}) + addView(f, 95.0f); // a little over 90° so PCF near face edges stays inside the frustum + } + ClusterView cv{camera_.origin, camFwd, camRight, camUp, std::tan(glm::radians(camera_.fovY) * 0.5f), float(width) / float(height)}; + buildClusters(cv, lights_, kMaxLightIndices, clusters_); + p.clusters = &clusters_; + lightStats_ = {uint32_t(lights_.size()), uint32_t(shadowLights), uint32_t(p.views.size()), uint32_t(clusters_.indices.size())}; + frame.camPos = glm::vec4(camera_.origin, float(lights_.size())); + frame.weather = weather_; + p.skyCloud = cloud_; + weather_ = glm::vec4(0.0f), cloud_ = 0.0f; // (set again next frame by whoever has weather) + frame.camFwd = glm::vec4(camFwd, r_lightdebug.f()); + frame.screen = glm::vec4(float(width), float(height), float(kAtlasSlot), float(kAtlasSize)); + if (r_drawlights.b()) + for (const DynamicLight& l : lights_) drawBox(l.origin - glm::vec3(4), l.origin + glm::vec3(4), glm::vec4(glm::min(l.color, glm::vec3(1)), 1)); + + // ── Draw lists ── + auto translucent = [](const ModelDraw& d, const Submesh& s) { return d.tint.a * s.color.a < 0.99f; }; + auto mesh = [&](const ModelDraw& d, const Submesh& s, bool shadow) { + return MeshDraw{d.model, s.firstIndex, s.indexCount, d.drawIndex, d.firstJoint, (d.texture ? d.texture : s.texture) | (s.lightmap << 16), + packColor(d.tint * s.color), shadow ? 0u : s.material | (s.skyVisibility << 16), d.liquid}; + }; + p.opaque.clear(); + p.translucent.clear(); + for (auto& list : sunCasters_) list.clear(); + drawStats_ = {}; + const bool cull = r_cull.b(); + // Occlusion: ask for this frame's grid, take back an older one, and use it only while the + // camera is still close to where it was drawn from (else things could pop in late). + takeOcclusion(); + p.hzbTag = 0; + if (r_occlusion.b()) { + p.hzbTag = ++hzbTag_; + hzbPending_.push_back({p.hzbTag, {p.viewProj, camera_.origin, camFwd}}); + if (hzbPending_.size() > 8) hzbPending_.erase(hzbPending_.begin()); + } + // On the GPU when it can (a frame fresher, no read-back); else here, from an older grid. + p.gpuCull = cull && r_occlusion.b() && r_gpuculling.b() && backend_->gpuCulling(); + p.cullOcclusion = p.gpuCull && lastCull_.valid && glm::length(camera_.origin - lastCull_.origin) < 48.0f && glm::dot(camFwd, lastCull_.forward) > 0.985f; + p.cullViewProj = lastCull_.viewProj; + lastCull_ = {true, p.viewProj, camera_.origin, camFwd}; + const uint32_t gpuHidden = p.gpuCull ? backend_->gpuCulled() : 0; + // (The CPU's test still covers translucent things when the GPU does the opaque ones.) + const bool cpuOcclusion = cull && r_occlusion.b() && hzbValid_ && glm::length(camera_.origin - hzbFrame_.origin) < 48.0f && + glm::dot(camFwd, hzbFrame_.forward) > 0.985f; + const Frustum view(p.viewProj, false); + const Frustum cascade[4] = {Frustum(frame.cascades[0], true), Frustum(frame.cascades[1], true), Frustum(frame.cascades[2], true), + Frustum(frame.cascades[3], true)}; + atlasCasters_.resize(p.views.size()); + for (auto& list : atlasCasters_) list.clear(); + std::vector> sorted; + drawStats_.occluded += gpuHidden; // the GPU's (a frame or two ago) + // Real-time GI: everything solid and still-shaped, in view or not, for the rays to hit. + const bool rays = r_gi.b() && backend_->rayTracing(); + p.rayInstances.clear(); + p.frame.gi = rays ? glm::vec4(1.0f, 64.0f, r_gi.i() == 2 ? 1.0f : 0.0f, 0.0f) : glm::vec4(0.0f); // z: r_gi 2 shows only the bounced light + // Mirrors: the nearest one in view that faces you gets the room drawn again, reflected in it. + const ModelDraw* mirrorDraw = nullptr; + const Submesh* mirrorSub = nullptr; + glm::vec4 mirrorPlane(0.0f); + if (r_mirrors.b() && backend_->mirrors()) { + float best = 1e30f; + for (const ModelDraw& d : modelDraws_) { + if (d.shadowOnly) continue; + for (const Submesh& s : models_[d.model].submeshes) { + if (!s.mirror || !view.sees(d.transform, s.lo, s.hi)) continue; + const glm::vec3 n = glm::normalize(glm::transpose(glm::inverse(glm::mat3(d.transform))) * glm::vec3(s.plane)); + const glm::vec3 on = glm::vec3(d.transform * glm::vec4(glm::vec3(s.plane) * s.plane.w, 1.0f)); + const float dist = glm::dot(n, on); + if (glm::dot(n, camera_.origin) - dist < 0.5f) continue; // behind it + const glm::vec3 centre = glm::vec3(d.transform * glm::vec4((s.lo + s.hi) * 0.5f, 1.0f)); + if (const float far = glm::length(centre - camera_.origin); far < best) best = far, mirrorDraw = &d, mirrorSub = &s, mirrorPlane = glm::vec4(n, dist); + } + } + } + p.mirror = mirrorDraw != nullptr; + p.mirrorOpaque.clear(); + p.mirrorTranslucent.clear(); + p.frame.mirror = p.frame.clip = glm::vec4(0.0f); + std::optional mirrorView; + if (p.mirror) { + // Reflection in the plane n·x = d: x − 2(n·x − d)n. + const glm::vec3 n(mirrorPlane); + glm::mat4 reflect(1.0f); + for (int c = 0; c < 3; ++c) + for (int r = 0; r < 3; ++r) reflect[c][r] -= 2.0f * n[c] * n[r]; + reflect[3] = glm::vec4(2.0f * mirrorPlane.w * n, 1.0f); + p.mirrorViewProj = p.viewProj * reflect; + p.mirrorInvViewProj = glm::inverse(p.mirrorViewProj); + p.frame.mirror = glm::vec4(float(kMirrorSlot), 0, 0, 0); + mirrorView.emplace(p.mirrorViewProj, false); + // Only where the mirror is on screen gets drawn (its box's corners; all of it when the + // box reaches behind the eye). + glm::vec2 lo(1.0f), hi(-1.0f); + bool behind = false; + for (int k = 0; k < 8; ++k) { + const glm::vec3 c(k & 1 ? mirrorSub->hi.x : mirrorSub->lo.x, k & 2 ? mirrorSub->hi.y : mirrorSub->lo.y, k & 4 ? mirrorSub->hi.z : mirrorSub->lo.z); + const glm::vec4 q = p.viewProj * mirrorDraw->transform * glm::vec4(c, 1.0f); + if (q.w < 0.1f) { behind = true; break; } + lo = glm::min(lo, glm::vec2(q) / q.w), hi = glm::max(hi, glm::vec2(q) / q.w); + } + if (behind) lo = glm::vec2(-1.0f), hi = glm::vec2(1.0f); + lo = glm::clamp(lo, -1.0f, 1.0f), hi = glm::clamp(hi, -1.0f, 1.0f); + // NDC to pixels (y down, as rows run), a pixel of margin. + const glm::vec2 size(float(p.width), float(p.height)); + const glm::ivec2 a = glm::max(glm::ivec2(glm::floor((lo * 0.5f + 0.5f) * size)) - 2, glm::ivec2(0)); + const glm::ivec2 b = glm::min(glm::ivec2(glm::ceil((hi * 0.5f + 0.5f) * size)) + 2, glm::ivec2(size)); + p.mirrorRect = glm::ivec4(a, glm::max(b - a, glm::ivec2(1))); + + mirrorReflect_ = reflect; + mirrorClip_ = glm::vec4(n, mirrorPlane.w - 0.25f); // (the wall it hangs on is cut too) + } + for (const ModelDraw& d : modelDraws_) { + const Model& m = models_[d.model]; + for (const Submesh& s : m.submeshes) { + // In the mirror: everything in front of it (your own body too), but not your hands. + if (mirrorView && !d.viewModel && !s.mirror && mirrorView->sees(d.transform, s.lo, s.hi)) + (translucent(d, s) ? p.mirrorTranslucent : p.mirrorOpaque).push_back(mesh(d, s, false)); + if (rays && !d.liquid && d.firstJoint == UINT32_MAX && !translucent(d, s)) + p.rayInstances.push_back({d.transform, d.model, s.firstIndex, s.texture | (s.lightmap << 16), packColor(d.tint * s.color)}); + bool seen = !cull || view.sees(d.transform, s.lo, s.hi); + const bool occlusion = cpuOcclusion && (!p.gpuCull || translucent(d, s)); + if (seen && occlusion && !d.shadowOnly && occluded(d.transform, s.lo, s.hi)) { + seen = false; + ++drawStats_.occluded; + } + if (translucent(d, s)) { + if (!seen) continue; + ++drawStats_.translucent; + if (!d.shadowOnly) sorted.emplace_back(glm::length((d.boundsMin + d.boundsMax) * 0.5f - camera_.origin) + (d.liquid ? 2.0f : 0.0f), + mesh(d, s, false)); + continue; + } + if (!d.shadowOnly && seen) { + p.opaque.push_back(mesh(d, s, false)); + if (&d == mirrorDraw && &s == mirrorSub) p.opaque.back().textures = kMirrorSlot | (s.lightmap << 16); // shows its picture + if (p.gpuCull) { // its world box, for the GPU's test + glm::vec3 lo(1e30f), hi(-1e30f); + for (int k = 0; k < 8; ++k) { + const glm::vec3 c(k & 1 ? s.hi.x : s.lo.x, k & 2 ? s.hi.y : s.lo.y, k & 4 ? s.hi.z : s.lo.z); + const glm::vec3 w = glm::vec3(d.transform * glm::vec4(c, 1.0f)); + lo = glm::min(lo, w), hi = glm::max(hi, w); + } + p.opaque.back().lo = lo, p.opaque.back().hi = hi; + } + ++drawStats_.drawn; + drawStats_.triangles += s.indexCount / 3; + } else if (!d.shadowOnly) { + ++drawStats_.culled; + } + if (d.castShadow && sunEnabled_) + for (int c = 0; c < 4; ++c) + if (!cull || cascade[c].sees(d.transform, s.lo, s.hi)) sunCasters_[c].push_back(mesh(d, s, true)), ++drawStats_.sunShadow; + // Light shadows: only things inside the light's sphere (and everything casts, the world too, + // but not first person's arms and tool: they're drawn where the camera is, not in the world). + for (size_t v = 0; v < viewSpheres.size() && !d.viewModel; ++v) { + const glm::vec3 q = glm::clamp(viewSpheres[v].center, d.boundsMin, d.boundsMax); + if (glm::dot(q - viewSpheres[v].center, q - viewSpheres[v].center) <= viewSpheres[v].radius * viewSpheres[v].radius) + atlasCasters_[v].push_back(mesh(d, s, true)), ++drawStats_.lightShadow; + } + } + } + // Shadow passes only write depth (no materials, no alpha test), so neighbouring submeshes of + // one model drawn the same way merge into one draw when their indices follow on. + auto merge = [](std::vector& list) { + size_t out = 0; + for (size_t i = 0; i < list.size(); ++i) { + if (out > 0) { + MeshDraw& last = list[out - 1]; + const MeshDraw& d = list[i]; + if (d.model == last.model && d.draw == last.draw && d.firstJoint == last.firstJoint && d.firstIndex == last.firstIndex + last.indexCount) { + last.indexCount += d.indexCount; + continue; + } + } + list[out++] = list[i]; + } + list.resize(out); + }; + drawStats_.sunShadow = drawStats_.lightShadow = 0; + for (auto& list : sunCasters_) merge(list), drawStats_.sunShadow += uint32_t(list.size()); + for (auto& list : atlasCasters_) merge(list), drawStats_.lightShadow += uint32_t(list.size()); + std::sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.first > b.first; }); // back to front + for (auto& [dist, m] : sorted) p.translucent.push_back(m); + + p.sunPasses.clear(); + if (sunEnabled_) { + const int half = int(p.shadowSize / 2); + for (int c = 0; c < 4; ++c) p.sunPasses.push_back({frame.cascades[c], glm::ivec4((c & 1) * half, (c >> 1) * half, half, half), &sunCasters_[c]}); + } + p.atlasPasses.clear(); + for (size_t v = 0; v < p.views.size(); ++v) { + const glm::vec4 r = p.views[v].rect * float(kAtlasSize); + p.atlasPasses.push_back({p.views[v].viewProj, glm::ivec4(r), &atlasCasters_[v]}); + } + + // ── Sky and air ── + // The sky only needs directions: build its matrix with the camera at the origin, so it + // stays exact however far from the map the camera is (a full one loses all precision there). + { + Camera at0 = camera_; + at0.origin = glm::vec3(0.0f); + p.invViewProj = glm::inverse(viewProjection(at0, float(width) / float(height))); + } + p.skyPhotoSunZ = 2.0f; + if (sky_.texture) { + const float intensity = atmosphere_.skyIntensity * std::max(r_skyintensity.f(), 0.0f); + // Turn the photo so its sun stands where the map's does. + float yaw = 0.0f; + if (sunEnabled_ && glm::length(glm::vec2(sunDir_)) > 1e-3f && glm::length(glm::vec2(sky_.sunDir)) > 1e-3f) + yaw = std::atan2(sunDir_.y, sunDir_.x) - std::atan2(sky_.sunDir.y, sky_.sunDir.x); + frame.sky = glm::vec4(float(sky_.texture), intensity, float(sky_.levels - 1), yaw); + p.skyPhotoSunZ = glm::length(sky_.sunDir) > 0.5f ? sky_.sunDir.z : 2.0f; + // The SH rotate with the photo: bands 0/2 (z) stay, x/y pairs turn by yaw (and 2·yaw). + const float c = std::cos(yaw), sn = std::sin(yaw), c2 = std::cos(2 * yaw), s2 = std::sin(2 * yaw); + glm::vec3 sh[9]; + for (int i = 0; i < 9; ++i) sh[i] = sky_.sh[i] * intensity; + // Band 1: (y, z, x) — rotating the world by yaw about z: x' = c·x − s·y, y' = s·x + c·y. + const glm::vec3 y1 = sh[1], x1 = sh[3]; + sh[1] = c * y1 + sn * x1; + sh[3] = c * x1 - sn * y1; + // Band 2: (xy, yz, z², xz, x²−y²): (xz, yz) turn by yaw, (x²−y², xy) by 2·yaw. + const glm::vec3 yz = sh[5], xz = sh[7], xy = sh[4], xx = sh[8]; + sh[5] = c * yz + sn * xz; + sh[7] = c * xz - sn * yz; + sh[4] = c2 * xy + s2 * xx; + sh[8] = c2 * xx - s2 * xy; + for (int i = 0; i < 9; ++i) frame.skySH[i] = glm::vec4(sh[i], 0.0f); + if (r_fog.b()) frame.fog = glm::vec4(atmosphere_.fogDensity * std::max(r_fogscale.f(), 0.0f), atmosphere_.fogFalloff, atmosphere_.fogMax, atmosphere_.fogBaseZ); + } + // The fog grid, when there's fog or light in the air to fill it with. + const bool airLit = frame.fog.x > 0.0f || frame.volumetric.x > 0.0f || !p.smoke.empty(); + frame.froxel = airLit && r_volumetricfog.b() && backend_->froxelFog() ? glm::vec4(1.0f, 16.0f, 16384.0f, 0.0f) : glm::vec4(0.0f); + + // Sky is given in display colours; undo exposure so it shows as typed. + glm::vec3 sky(0.5f); + std::sscanf(r_skycolor.str().c_str(), "%f %f %f", &sky.r, &sky.g, &sky.b); + p.exposure = std::max(r_exposure.f(), 0.01f); + // Night stays night: the eye adapts, but not all the way back to day. + p.exposure *= glm::mix(1.0f, 0.3f, std::clamp(p.frame.weather.z, 0.0f, 1.0f)); + const float tanY = std::tan(glm::radians(camera_.fovY) * 0.5f); + p.post.aoStrength = std::clamp(r_ssao.f(), 0.0f, 1.0f); // (the TAA fields were set with the camera above) + p.post.aoRadius = std::max(r_ssao_radius.f(), 1.0f); + p.post.bloomStrength = std::clamp(r_bloom.f(), 0.0f, 1.0f); + p.post.saturation = r_saturation.f(); + p.post.vignette = r_vignette.f(); + p.post.sharpen = std::max(r_sharpen.f(), 0.0f); + p.post.ssr = std::max(r_ssr.f(), 0.0f); + p.prepass = r_prepass.b(); + { // Particles face the camera; blended ones are drawn back to front. + const glm::vec3 f = forwardOf(camera_), right = glm::normalize(glm::cross(f, glm::vec3(0, 0, 1) + glm::vec3(0.0001f, 0, 0))), + up = glm::cross(right, f); + for (auto* list : {&p.sprites, &p.glowSprites}) + for (gpu::Sprite& s : *list) { + if (const glm::vec3 half(s.up); glm::dot(half, half) > 0.0f) { // a streak: its side faces the eye + glm::vec3 side = glm::cross(half, camera_.origin - glm::vec3(s.posSize)); + side = glm::length(side) > 1e-6f ? glm::normalize(side) : right; + s.right = glm::vec4(side * s.posSize.w, 0.0f); + s.posSize.w = 1.0f; + continue; + } + s.right = glm::vec4(right, s.right.w), s.up = glm::vec4(up, s.up.w); + } + std::sort(p.sprites.begin(), p.sprites.end(), [&](const gpu::Sprite& a, const gpu::Sprite& b) { + return glm::dot(glm::vec3(a.posSize) - camera_.origin, f) > glm::dot(glm::vec3(b.posSize) - camera_.origin, f); + }); + } + { + static double lastTime = keel::now(); + const double t = keel::now(); + p.post.adaptDt = float(std::clamp(t - lastTime, 0.0, 0.25)); + lastTime = t; + const bool on = r_autoexposure.b() && p.post.bloomStrength > 0.0f; // (it reads the bloom chain) + p.post.adaptReset = on && !adaptOn_; + p.post.adapt = adaptOn_ = on; + } + p.post.shafts = glm::vec4(0.0f); + if (sunEnabled_ && sunDir_.z > -0.05f && r_shafts.f() > 0.0f) { // the sun on screen, or just off it + const glm::vec4 c = p.viewProj * glm::vec4(camera_.origin + sunDir_ * 10000.0f, 1.0f); + if (c.w > 0.0f) { + const glm::vec2 uv = glm::vec2(c) / c.w * 0.5f + 0.5f; + const float onScreen = 1.0f - glm::smoothstep(0.6f, 1.4f, glm::length((uv - 0.5f) * 2.0f) - 0.4f); + p.post.shafts = glm::vec4(uv, r_shafts.f() * onScreen * glm::smoothstep(-0.05f, 0.15f, sunDir_.z) * (1.0f - p.skyCloud), 0.0f); // (none through cloud) + p.post.sunColor = glm::vec4(sunColor_, 0.0f); + } + } + p.post.proj = glm::vec4(tanY * float(width) / float(height), tanY, 2.0f, 16384.0f); + p.clearColor = glm::vec3(srgbToLinear(sky.r), srgbToLinear(sky.g), srgbToLinear(sky.b)) / p.exposure; + // The mirror's pass: this frame's data, but seen from the reflected eye, cut at the mirror, + // lit by every light (the clusters are this view's), and no fog grid (that's this view's too). + if (p.mirror) { + p.mirrorFrame = p.frame; + const float lights = p.frame.camPos.w, debug = p.frame.camFwd.w; + p.mirrorFrame.camPos = glm::vec4(glm::vec3(mirrorReflect_ * glm::vec4(glm::vec3(p.frame.camPos), 1.0f)), lights); + p.mirrorFrame.camFwd = glm::vec4(glm::normalize(glm::vec3(mirrorReflect_ * glm::vec4(glm::vec3(p.frame.camFwd), 0.0f))), debug); + p.mirrorFrame.mirror = glm::vec4(0.0f, 1.0f, 0.0f, 0.0f); + p.mirrorFrame.froxel = glm::vec4(0.0f); + p.mirrorFrame.clip = mirrorClip_; + } +} + +} // namespace keel diff --git a/engine/render/renderer.h b/engine/render/renderer.h new file mode 100644 index 0000000..27431bb --- /dev/null +++ b/engine/render/renderer.h @@ -0,0 +1,260 @@ +#pragma once +#include "render/backend.h" +#include "render/clusters.h" + +#include + +#include +#include +#include +#include +#include + +namespace keel { + +class Window; +struct KmdlModel; + +struct Camera { + glm::vec3 origin{0.0f}; + float yaw = 0.0f; // degrees, 0 = +X, counter-clockwise + float pitch = 0.0f; // degrees, positive looks down (Quake convention) + float roll = 0.0f; // degrees, positive tilts right + float fovY = 74.0f; // degrees + glm::vec2 shift{0.0f}; // where the picture's middle sits on the screen (-1..1 across, down): a view beside panels +}; + +// Baked light for a moving model (from the map's probes): ambient cube + sun visibility. +struct ModelLighting { + glm::vec3 cube[6]; // light arriving from +X -X +Y -Y +Z -Z + float sunVisibility = 1.0f; +}; + +// Liquid shading: a thin film on a surface (puddles, splats; thicker towards the middle of the +// model's UV v = 0 end, with an uneven edge), or a glossy body (drops, what's in a bottle). +enum class LiquidStyle { None = 0, Film = 1, Body = 2, Water = 3, Decal = 4 }; // Water: open water's surface (waves, deep colour) + +struct ModelId { + uint32_t index = UINT32_MAX; + bool valid() const { return index != UINT32_MAX; } +}; + +// World space is Quake-like: Z up, units roughly inches. The scene renders in HDR and is +// tonemapped to the screen; UI draws on top. The graphics API sits behind a RenderBackend +// (Vulkan or OpenGL 4.6), picked at startup. +class Renderer { +public: + bool init(const Window& window, bool validation, RenderApi api); + void shutdown(); + const char* apiName() const { return backend_ ? backend_->name() : "none"; } + GpuInfo gpuInfo() const { return backend_ ? backend_->gpuInfo() : GpuInfo{}; } + // Starts a Dear ImGui frame; UI code runs between this and drawFrame(). + void beginUi(); + + // Loaded through the virtual filesystem and cached by path. + TextureId loadTexture(std::string_view path); // .yapf + // A picture made in memory (8-bit RGBA, sRGB), cached under `name` like a loaded file, so + // models whose material names it use it; made again in place if the name's taken. (Fry3DX: + // reference images.) + TextureId createTexture(std::string_view name, uint32_t w, uint32_t h, const uint8_t* rgba); + // Hot reload: textures and models whose files changed since they were loaded are loaded + // again in place (same ids). Returns their paths. + std::vector reloadChanged(); + ModelId loadModel(std::string_view path); // .kmdl + // A model built in memory (e.g. map geometry), cached under `name`. + ModelId createModel(std::string_view name, const KmdlModel& model); + // Replaces (or creates) the model cached under `name`; the old GPU buffers are freed + // once no frame in flight can still use them. For meshes that change (breaking glass). + ModelId updateModel(std::string_view name, const KmdlModel& model); + glm::vec3 modelMins(ModelId m) const { return m.valid() ? models_[m.index].mins : glm::vec3(0); } + glm::vec3 modelMaxs(ModelId m) const { return m.valid() ? models_[m.index].maxs : glm::vec3(0); } + + // A loaded texture for ImGui::Image (ImTextureID); 0 if it isn't one. + uint64_t uiTexture(TextureId texture) { return backend_ ? backend_->uiTexture(texture) : 0; } + // Pixel size of a loaded texture. + glm::ivec2 textureSize(TextureId texture) const { + const auto it = textureSizes_.find(texture); + return it == textureSizes_.end() ? glm::ivec2(0) : it->second; + } + + void setCamera(const Camera& cam) { camera_ = cam; } + const Camera& camera() const { return camera_; } + // Volumetric smoke this frame (SmokeField::bake over kSmokeDims cells from `corner`); `clock` + // turns its billows. Nothing set: none. + void setSmoke(const glm::vec3& corner, std::vector cells, float clock) { + packet_.smoke = std::move(cells); + packet_.smokeOrigin = glm::vec4(corner, clock); + } + // The camera's view-projection (clip space: Vulkan's, Y down), for placing 2D overlays on the world. + glm::mat4 cameraViewProjection(float aspect) const; + void drawBox(const glm::vec3& mins, const glm::vec3& maxs, const glm::vec4& color, TextureId texture = kWhiteTexture); + // A particle: a camera-facing square `size` across, unlit (color can be HDR-bright). + // additive: light added on (fire, sparks, flashes); else blended (smoke, dust). + void drawSprite(const glm::vec3& at, float size, const glm::vec4& color, TextureId texture, float rotation = 0.0f, bool additive = true); + // A sprite stretched from `from` to `to`, `width` across, turned to face the eye as much as + // it can (rain). + void drawStreak(const glm::vec3& from, const glm::vec3& to, float width, const glm::vec4& color, TextureId texture, bool additive = false); + // skin: one matrix per joint (anim::skinMatrices) for skinned models; ignored otherwise. + // lighting: probe light for moving things (nullptr = lightmap or simple sun shading). + void drawModel(ModelId model, const glm::mat4& transform, const glm::vec4& tint = glm::vec4(1.0f), + const std::vector* skin = nullptr, const ModelLighting* lighting = nullptr, bool castShadow = true, + const glm::vec4* liquidPlane = nullptr, LiquidStyle liquid = LiquidStyle::None); + // liquidPlane (world space: xyz normal, w distance): draws a closed mesh as liquid filling it + // up to the plane — above it is cut away, and its back faces show as the flat surface. + // Alpha below 1 (tint × material) makes a submesh translucent: sorted, blended, after opaque. + // The map's sun; dir points towards it. realtime: the lightmap has no direct sun, so it is + // drawn here for every surface with cascaded shadows (the world casts too). Otherwise + // it only lights probe-lit models and their shadows darken the baked light. + void setSun(bool enabled, const glm::vec3& dir, const glm::vec3& color, bool realtime = false); + // The sky (a .ksky; "" = none, the flat r_skycolor) and the air: its brightness and height + // fog (density per unit at baseZ, thinning out by `falloff` per unit up). + struct Atmosphere { + std::string sky; + float skyIntensity = 0.35f; + float fogDensity = 0.00005f, fogFalloff = 0.0005f, fogMax = 0.9f, fogBaseZ = 0.0f; + }; + void setAtmosphere(const Atmosphere& a); + // This frame's weather (FrameData::weather): x wet, y snow cover, z night, w lightning flash. + // Per frame, like draws: not set, it's clear. + void setWeather(const glm::vec4& w, float cloud = 0.0f) { weather_ = w, cloud_ = cloud; } + // The game's clock (seconds): water's swells move with it, so floating things match them. + void setClock(double t) { clock_ = t, hasClock_ = true; } + // Reflection probes (env_cubemap, baked by assetc as .ksky): glossy surfaces reflect the + // nearest one. Call with the map's list every frame (it's only reloaded when it changes). + void setProbes(const std::vector>& probes); + // Draws made while this is on only cast shadows (sun, dynamic lights) and aren't seen: + // the first-person player's own body. + void setShadowOnly(bool on) { shadowOnly_ = on; } + // What's drawn next is the first-person view model (mirrors show your body, not these). + void setViewModel(bool on) { viewModel_ = on; } + // Models drawn next wear this picture instead of their own (0: their own): one + // model, many pictures. + void setTextureOverride(TextureId texture) { textureOverride_ = texture; } + // A real-time light for this frame only (call every frame, like drawModel). + void addLight(const DynamicLight& light) { lights_.push_back(light); } + // Stats from the last frame, for r_lightinfo / tools. + struct LightStats { uint32_t lights = 0, shadowed = 0, shadowViews = 0, indices = 0; }; + const LightStats& lightStats() const { return lightStats_; } + // What the last frame drew (r_speeds): submesh draws in the view, culled away, into the sun's + // cascades and lights' shadows. + // Where the last frame's time went (r_profile): CPU (ms) and each GPU pass. + struct Timings { + float wait = 0, build = 0, record = 0; // waiting for a free frame (the GPU), building the draw list, recording + submitting + std::vector> gpu; + }; + const Timings& timings() const { return timings_; } + struct DrawStats { uint32_t drawn = 0, culled = 0, occluded = 0, translucent = 0, sunShadow = 0, lightShadow = 0, triangles = 0; }; + const DrawStats& drawStats() const { return drawStats_; } + void drawFrame(); + // Saves the next presented frame (UI included) as a PNG. + void requestScreenshot(std::string path) { screenshotPath_ = std::move(path); } + void onResize() { + if (backend_) backend_->onResize(); + } + +private: + struct Submesh { + uint32_t firstIndex, indexCount; + TextureId texture, lightmap; + glm::vec4 color; + TextureId material = 0; // "_n": relief (tangent-space normal) + gloss in alpha; 0 = none + TextureId skyVisibility = 0; // "_sky": how much sky each luxel sees; 0 = none + glm::vec3 lo{0.0f}, hi{0.0f}; // model-space bounds, for culling + bool mirror = false; // its texture's a mirror ("mirror" in its path): shows what's in front + glm::vec4 plane{0.0f}; // a mirror's plane (model space): normal out of it, distance + }; + struct Model { + std::vector submeshes; + glm::vec3 mins{0.0f}, maxs{0.0f}; + bool skinned = false; + }; + struct ModelDraw { + uint32_t model; + glm::mat4 transform; + glm::vec4 tint; + uint32_t firstJoint = UINT32_MAX; // into the packet's joints, skinned draws only + uint32_t drawIndex = 0; // into the packet's draws + bool castShadow = true; // sun shadow map (the world is baked, so it doesn't) + bool shadowOnly = false; // not drawn in the view + bool liquid = false; // sorted just behind what holds it (liquid in a glass bottle) + bool viewModel = false; // your hands and tool in first person (not seen in mirrors) + TextureId texture = 0; // worn instead of the model's own pictures (0: its own) + glm::vec3 boundsMin, boundsMax; // world space, for culling shadow casters per light + }; + + Model describeModel(const KmdlModel& kmdl); + TextureId newTexture(uint32_t w, uint32_t h, bool srgb, const std::vector>& mips, TexFormat format = TexFormat::RGBA8); + void buildPacket(uint32_t width, uint32_t height); + void clearFrame(); + + std::unique_ptr backend_; + TextureId nextTexture_ = kFirstTextureSlot; + std::unordered_map textureCache_; + std::unordered_map textureSizes_; + std::vector models_; + std::unordered_map modelCache_; + std::unordered_map fileTimes_; // loaded files' times (hot reload) + bool decodeTexture(const std::string& path, uint32_t& w, uint32_t& h, bool& srgb, std::vector>& mips, TexFormat& format); + + DrawStats drawStats_; + // TAA: last frame's camera. + glm::mat4 taaPrevViewProj_{1.0f}; + glm::vec3 taaPrevOrigin_{0.0f}; + uint32_t taaPrevW_ = 0, taaPrevH_ = 0, taaFrame_ = 0; + bool taaPrevValid_ = false; + // Occlusion (hierarchical Z): the newest grid back from the GPU as a max-depth pyramid, + // with the camera it was drawn from. Frames are tagged; their cameras are remembered until + // their grid returns. + struct HzbFrame { glm::mat4 viewProj; glm::vec3 origin, forward; }; + std::vector> hzbLevels_; // level 0 = kHzbWidth × kHzbHeight + HzbFrame hzbFrame_{}; + struct { bool valid = false; glm::mat4 viewProj{1.0f}; glm::vec3 origin{0.0f}, forward{1, 0, 0}; } lastCull_; // GPU culling: last frame's camera + bool hzbValid_ = false; + uint64_t hzbTag_ = 0; + std::vector> hzbPending_; + void takeOcclusion(); + bool occluded(const glm::mat4& transform, const glm::vec3& lo, const glm::vec3& hi) const; + Camera camera_; + bool sunEnabled_ = false, sunRealtime_ = false; + struct Sky { + TextureId texture = 0; + uint32_t levels = 0; + glm::vec3 sunDir{0, 0, 1}; + glm::vec3 sh[9]{}; + }; + std::unordered_map skies_; + const Sky* loadSky(const std::string& path); + struct Probe { + glm::vec3 at; + TextureId texture; + float top; + }; + std::vector probes_; + bool adaptOn_ = false; + float dynScale_ = 1.0f; // dynamic resolution's current scale + double dynFrame_ = 0.016; // smoothed frame time + std::vector> probeList_; + Sky sky_; + Atmosphere atmosphere_; + glm::vec3 sunDir_{0, 0, 1}, sunColor_{1.0f}; + std::vector modelDraws_; + std::vector lights_; + ClusterGrid clusters_; + LightStats lightStats_; + Timings timings_; + std::string screenshotPath_; + bool shadowOnly_ = false, viewModel_ = false; + TextureId textureOverride_ = 0; + glm::mat4 mirrorReflect_{1.0f}; // this frame's mirror: its reflection, and its pass's cut plane + glm::vec4 mirrorClip_{0.0f}; + glm::vec4 weather_{0.0f}; + float cloud_ = 0.0f; + double clock_ = 0.0; + bool hasClock_ = false; + + FramePacket packet_; // reused every frame + std::vector sunCasters_[4]; // per cascade, culled to it + std::vector> atlasCasters_; +}; + +} // namespace keel diff --git a/engine/render/resources.cpp b/engine/render/resources.cpp new file mode 100644 index 0000000..f05d940 --- /dev/null +++ b/engine/render/resources.cpp @@ -0,0 +1,293 @@ +// Textures and models: loaded through the VFS, described here, uploaded by the backend. +#include "asset/kmdl.h" +#include "core/log.h" +#include "fs/vfs.h" +#include "render/renderer.h" + +#include + +#include +#include +#include + +namespace keel { + +TextureId Renderer::newTexture(uint32_t w, uint32_t h, bool srgb, const std::vector>& mips, TexFormat format) { + if (nextTexture_ >= kMaxTextures) return kMissingTexture; + const TextureId id = nextTexture_++; + backend_->createTexture(id, w, h, srgb, mips, format); + textureSizes_[id] = glm::ivec2(int(w), int(h)); + return id; +} + +// Why YAPF couldn't read a file, in words. +static const char* yapfReason(int err) { + switch (err) { + case YAPF_ERR_INVALID: return "not a YAPF file"; + case YAPF_ERR_IO: return "couldn't be read"; + case YAPF_ERR_OOM: return "out of memory"; + case YAPF_ERR_CORRUPT: return "damaged"; + case YAPF_ERR_UNSUPPORTED: return "uses a feature this YAPF can't decode (a newer version?)"; + case YAPF_ERR_CHECKSUM: return "damaged (checksum mismatch)"; + case YAPF_ERR_TRUNCATED: return "truncated"; + default: return "unknown error"; + } +} + +// A .yapf file (version 1 or 2) as mips for the GPU: BC7 blocks go up as they are when the GPU +// takes them; anything else (other block formats, 16-bit, half, float, gray) is decoded to RGBA8 +// by YAPF (yapf_tex_to_rgba8). +bool Renderer::decodeTexture(const std::string& key, uint32_t& w, uint32_t& h, bool& srgb, std::vector>& mips, + TexFormat& format) { + auto data = fs::read(key); + if (!data) { + log::warn("Texture not found: {}", key); + return false; + } + int err = 0; + yapf_texture_t* tex = yapf_tex_load_memory(data->data(), data->size(), 0, 0, &err); + if (!tex) { + log::warn("{}: can't load: {} (YAPF error {})", key, yapfReason(err), err); + return false; + } + w = tex->width, h = tex->height, srgb = tex->flags & YAPF_FLAG_SRGB; + // Float and half textures are linear: yapf_tex_to_rgba8 sRGB-encodes them. + if (tex->payload != YAPF_PAYLOAD_BLOCKS && (tex->sample == YAPF_SAMPLE_F16 || tex->sample == YAPF_SAMPLE_F32)) srgb = true; + const bool upload = tex->payload == YAPF_PAYLOAD_BLOCKS && tex->format == YAPF_GPU_BC7 && backend_->blockCompression(); + format = upload ? TexFormat::BC7 : TexFormat::RGBA8; + mips.clear(); + for (uint32_t level = 0; level < tex->mip_levels; ++level) { + uint32_t lw = 0, lh = 0, ld = 0; + yapf_tex_mip_size(tex, level, &lw, &lh, &ld); + const uint32_t surface = yapf_tex_surface(tex, 0, 0, 0, level); + if (upload) { + const uint8_t* src = static_cast(tex->data[surface]); + mips.emplace_back(src, src + yapf_tex_surface_bytes(tex, level)); + continue; + } + std::vector rgba(size_t(lw) * lh * 4); + if (const int e = yapf_tex_to_rgba8(tex, surface, 0, rgba.data()); e != YAPF_OK) { + log::warn("{}: mip {} can't be decoded: {}", key, level, yapfReason(e)); + yapf_tex_free(tex); + return false; + } + mips.push_back(std::move(rgba)); + } + yapf_tex_free(tex); + return true; +} + +TextureId Renderer::createTexture(std::string_view name, uint32_t w, uint32_t h, const uint8_t* rgba) { + // A full mip chain (box filtered), so a big picture seen small doesn't shimmer. + std::vector> mips(1, std::vector(rgba, rgba + size_t(w) * h * 4)); + for (uint32_t mw = w, mh = h; mw > 1 || mh > 1;) { + const uint32_t nw = std::max(mw / 2, 1u), nh = std::max(mh / 2, 1u); + const std::vector& src = mips.back(); + std::vector dst(size_t(nw) * nh * 4); + for (uint32_t y = 0; y < nh; ++y) + for (uint32_t x = 0; x < nw; ++x) + for (int c = 0; c < 4; ++c) { + const uint32_t x0 = std::min(x * 2, mw - 1), x1 = std::min(x * 2 + 1, mw - 1), y0 = std::min(y * 2, mh - 1), y1 = std::min(y * 2 + 1, mh - 1); + dst[(size_t(y) * nw + x) * 4 + c] = uint8_t((src[(size_t(y0) * mw + x0) * 4 + c] + src[(size_t(y0) * mw + x1) * 4 + c] + + src[(size_t(y1) * mw + x0) * 4 + c] + src[(size_t(y1) * mw + x1) * 4 + c] + 2) / 4); + } + mips.push_back(std::move(dst)); + mw = nw, mh = nh; + } + const std::string key(name); + if (auto it = textureCache_.find(key); it != textureCache_.end() && it->second >= kFirstTextureSlot) { + backend_->createTexture(it->second, w, h, true, mips, TexFormat::RGBA8); + textureSizes_[it->second] = glm::ivec2(int(w), int(h)); + return it->second; + } + return textureCache_[key] = newTexture(w, h, true, mips, TexFormat::RGBA8); +} + +TextureId Renderer::loadTexture(std::string_view path) { + if (path.empty()) return kWhiteTexture; + const std::string key(path); + if (auto it = textureCache_.find(key); it != textureCache_.end()) return it->second; + TextureId id = kMissingTexture; + uint32_t w = 0, h = 0; + bool srgb = false; + std::vector> mips; + TexFormat format; + if (decodeTexture(key, w, h, srgb, mips, format)) id = newTexture(w, h, srgb, mips, format); + if (auto t = fs::mtime(key)) fileTimes_[key] = *t; + textureCache_[key] = id; + return id; +} + +std::vector Renderer::reloadChanged() { + std::vector changed; + auto stale = [&](const std::string& path) { + const auto t = fs::mtime(path); + if (!t) return false; + auto [it, isNew] = fileTimes_.try_emplace(path, *t); + if (isNew || it->second == *t) return false; + it->second = *t; + return true; + }; + for (auto& [path, id] : textureCache_) { + if (id < kFirstTextureSlot || !stale(path)) continue; + uint32_t w = 0, h = 0; + bool srgb = false; + std::vector> mips; + TexFormat format; + if (!decodeTexture(path, w, h, srgb, mips, format)) continue; + backend_->createTexture(id, w, h, srgb, mips, format); // the same slot: everything using it updates + textureSizes_[id] = glm::ivec2(int(w), int(h)); + changed.push_back(path); + } + std::vector> models(modelCache_.begin(), modelCache_.end()); + for (auto& [path, id] : models) { + if (!id.valid() || path.starts_with("map:") || !stale(path)) continue; + auto data = fs::read(path); + auto kmdl = data ? readKmdl(*data) : std::nullopt; + if (!kmdl) continue; + updateModel(path, *kmdl); + changed.push_back(path); + } + return changed; +} + +Renderer::Model Renderer::describeModel(const KmdlModel& kmdl) { + Model m; + if (kmdl.vertices.empty() || kmdl.indices.empty()) return m; + m.mins = glm::vec3(kmdl.header.boundsMin[0], kmdl.header.boundsMin[1], kmdl.header.boundsMin[2]); + m.maxs = glm::vec3(kmdl.header.boundsMax[0], kmdl.header.boundsMax[1], kmdl.header.boundsMax[2]); + m.skinned = !kmdl.skin.empty(); + for (const KmdlSubmesh& s : kmdl.submeshes) { + const KmdlMaterial& mat = kmdl.materials[s.material]; + const std::string tex(mat.texture, strnlen(mat.texture, sizeof mat.texture)); + const std::string lm(mat.lightmap, strnlen(mat.lightmap, sizeof mat.lightmap)); + // A material map next to the texture ("wall.yapf" → "wall_n.yapf"), when there is one. + TextureId material = 0; + if (const size_t dot = tex.rfind('.'); !tex.empty() && dot != std::string::npos) + if (const std::string n = tex.substr(0, dot) + "_n" + tex.substr(dot); fs::exists(n)) material = loadTexture(n); + TextureId skyVis = 0; + if (const size_t dot = lm.rfind('.'); !lm.empty() && dot != std::string::npos) + if (const std::string v = lm.substr(0, dot) + "_sky" + lm.substr(dot); fs::exists(v)) skyVis = loadTexture(v); + glm::vec3 lo(1e30f), hi(-1e30f); + for (uint32_t i = s.firstIndex; i < s.firstIndex + s.indexCount && i < kmdl.indices.size(); ++i) { + const float* p = kmdl.vertices[kmdl.indices[i]].position; + lo = glm::min(lo, glm::vec3(p[0], p[1], p[2])); + hi = glm::max(hi, glm::vec3(p[0], p[1], p[2])); + } + if (m.skinned) lo = m.mins - (m.maxs - m.mins) * 0.5f, hi = m.maxs + (m.maxs - m.mins) * 0.5f; // animated: room to move + m.submeshes.push_back({s.firstIndex, s.indexCount, loadTexture(tex), lm.empty() ? 0u : loadTexture(lm), + glm::vec4(mat.baseColor[0], mat.baseColor[1], mat.baseColor[2], mat.baseColor[3]), material, skyVis, lo, hi}); + // A mirror: its plane from its first triangle, facing the way its vertices' normals do. + if (tex.find("mirror") != std::string::npos && s.indexCount >= 3 && !m.skinned) { + const KmdlVertex& va = kmdl.vertices[kmdl.indices[s.firstIndex]]; + const glm::vec3 a(va.position[0], va.position[1], va.position[2]); + const float* pb = kmdl.vertices[kmdl.indices[s.firstIndex + 1]].position; + const float* pc = kmdl.vertices[kmdl.indices[s.firstIndex + 2]].position; + glm::vec3 n = glm::cross(glm::vec3(pb[0], pb[1], pb[2]) - a, glm::vec3(pc[0], pc[1], pc[2]) - a); + if (glm::length(n) > 1e-6f) { + n = glm::normalize(n); + if (glm::dot(n, glm::vec3(va.normal[0], va.normal[1], va.normal[2])) < 0.0f) n = -n; + m.submeshes.back().mirror = true; + m.submeshes.back().plane = glm::vec4(n, glm::dot(n, a)); + } + } + } + return m; +} + +namespace { +// Mirrors reflect in one plane each, but a model keeps all the faces of a texture in one +// submesh (several mirrors, or a mirror brush's faces). Each mirror submesh with more than one +// plane is split into one per plane (its triangles sorted so each runs on). False: nothing to do. +bool splitMirrors(const KmdlModel& in, KmdlModel& out) { + auto isMirror = [&](const KmdlSubmesh& s) { + const KmdlMaterial& m = in.materials[s.material]; + return std::string(m.texture, strnlen(m.texture, sizeof m.texture)).find("mirror") != std::string::npos; + }; + auto planeOf = [&](uint32_t first) { + const float* a = in.vertices[in.indices[first]].position; + const float* b = in.vertices[in.indices[first + 1]].position; + const float* c = in.vertices[in.indices[first + 2]].position; + const glm::vec3 pa(a[0], a[1], a[2]); + glm::vec3 n = glm::cross(glm::vec3(b[0], b[1], b[2]) - pa, glm::vec3(c[0], c[1], c[2]) - pa); + if (glm::length(n) < 1e-6f) return glm::ivec4(0); + n = glm::normalize(n); + const float* vn = in.vertices[in.indices[first]].normal; + if (glm::dot(n, glm::vec3(vn[0], vn[1], vn[2])) < 0.0f) n = -n; + return glm::ivec4(glm::round(n * 1000.0f), int(std::round(glm::dot(n, pa) * 2.0f))); // within 0.001 / half a unit + }; + bool any = false; + for (const KmdlSubmesh& s : in.submeshes) { + if (!isMirror(s) || s.indexCount <= 3) continue; + const glm::ivec4 first = planeOf(s.firstIndex); + for (uint32_t t = s.firstIndex + 3; t + 2 < s.firstIndex + s.indexCount && !any; t += 3) any = planeOf(t) != first; + if (any) break; + } + if (!any) return false; + out = in; + out.submeshes.clear(); + for (const KmdlSubmesh& s : in.submeshes) { + if (!isMirror(s)) { + out.submeshes.push_back(s); + continue; + } + std::vector>> groups; // plane → its triangles' indices + for (uint32_t t = s.firstIndex; t + 2 < s.firstIndex + s.indexCount; t += 3) { + const glm::ivec4 pl = planeOf(t); + auto it = std::find_if(groups.begin(), groups.end(), [&](const auto& g) { return g.first == pl; }); + if (it == groups.end()) it = groups.insert(groups.end(), {pl, {}}); + it->second.insert(it->second.end(), {in.indices[t], in.indices[t + 1], in.indices[t + 2]}); + } + uint32_t at = s.firstIndex; + for (const auto& [pl, idx] : groups) { + std::copy(idx.begin(), idx.end(), out.indices.begin() + at); + out.submeshes.push_back({at, uint32_t(idx.size()), s.material}); + at += uint32_t(idx.size()); + } + } + return true; +} +} // namespace + +ModelId Renderer::createModel(std::string_view name, const KmdlModel& source) { + KmdlModel split; + const KmdlModel& kmdl = splitMirrors(source, split) ? split : source; + const std::string key(name); + if (auto it = modelCache_.find(key); it != modelCache_.end()) return it->second; + ModelId id; + if (!kmdl.vertices.empty() && !kmdl.indices.empty()) { + id.index = uint32_t(models_.size()); + models_.push_back(describeModel(kmdl)); + backend_->uploadModel(id.index, kmdl); + } + modelCache_[key] = id; + return id; +} + +ModelId Renderer::updateModel(std::string_view name, const KmdlModel& source) { + KmdlModel split; + const KmdlModel& kmdl = splitMirrors(source, split) ? split : source; + const auto it = modelCache_.find(std::string(name)); + if (it == modelCache_.end() || !it->second.valid()) { + if (it != modelCache_.end()) modelCache_.erase(it); + return createModel(name, kmdl); + } + models_[it->second.index] = describeModel(kmdl); // may be empty: draws nothing + backend_->uploadModel(it->second.index, kmdl); + return it->second; +} + +ModelId Renderer::loadModel(std::string_view path) { + const std::string key(path); + if (auto it = modelCache_.find(key); it != modelCache_.end()) return it->second; + if (auto t = fs::mtime(key)) fileTimes_[key] = *t; + auto data = fs::read(path); + auto kmdl = data ? readKmdl(*data) : std::nullopt; + if (!kmdl) { + log::warn("Model {} {}", key, data ? "is not a valid KMDL file" : "not found"); + return modelCache_[key] = ModelId{}; + } + return createModel(key, *kmdl); +} + +} // namespace keel diff --git a/engine/render/screenshot.cpp b/engine/render/screenshot.cpp new file mode 100644 index 0000000..6894f68 --- /dev/null +++ b/engine/render/screenshot.cpp @@ -0,0 +1,38 @@ +#include "render/screenshot.h" +#include "core/log.h" + +#define STB_IMAGE_WRITE_IMPLEMENTATION +#define STB_IMAGE_IMPLEMENTATION // (stb_image for the whole program: the window icon, and the app's pictures) +#include +#include + +#include +#include +#include +#include + +namespace keel { +namespace { +std::atomic g_pending{0}; +} + +void saveScreenshot(std::string path, std::vector rgba, uint32_t width, uint32_t height) { + // PNG compression is slow (very slow in debug builds); keep it off the render thread. + ++g_pending; + std::thread([px = std::move(rgba), path = std::move(path), width, height]() mutable { + for (size_t i = 0; i < size_t(width) * height; ++i) px[i * 4 + 3] = 255; + std::error_code ec; + std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec); + stbi_write_png_compression_level = 4; + if (stbi_write_png(path.c_str(), int(width), int(height), 4, px.data(), int(width * 4))) log::info("Saved {}", path); + else log::warn("Couldn't write {}", path); + --g_pending; + }).detach(); +} + +void waitForScreenshots() { + // Quitting right after a screenshot would cut it off half-written. + for (int i = 0; g_pending > 0 && i < 1000; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(10)); +} + +} // namespace keel diff --git a/engine/render/screenshot.h b/engine/render/screenshot.h new file mode 100644 index 0000000..adbb471 --- /dev/null +++ b/engine/render/screenshot.h @@ -0,0 +1,11 @@ +#pragma once +#include +#include +#include + +namespace keel { +// Writes RGBA8 pixels (row 0 at the top) to a PNG on a background thread; alpha is forced opaque. +void saveScreenshot(std::string path, std::vector rgba, uint32_t width, uint32_t height); +// Blocks until screenshots still being written are done (before the process exits). +void waitForScreenshots(); +} // namespace keel diff --git a/engine/rhi/gl/gl_backend.cpp b/engine/rhi/gl/gl_backend.cpp new file mode 100644 index 0000000..0814840 --- /dev/null +++ b/engine/rhi/gl/gl_backend.cpp @@ -0,0 +1,858 @@ +// The OpenGL 4.6 backend. Same shaders and passes as Vulkan: +// - textures are ARB_bindless_texture handles in a storage-buffer table, indexed exactly like +// Vulkan's descriptor array; shadow maps also get comparison handles in a second table +// - per-draw / per-frame data are storage buffers at fixed bindings (model.glsl); the push +// constants become a small uniform block, rewritten per draw +// - glClipControl(LOWER_LEFT, ZERO_TO_ONE): with GL's own origin, clip-space y = -1 lands in +// memory row 0 exactly as in Vulkan (whose projection we share, y flipped), so images, +// shadow-map tiles and gl_FragCoord all match; depth is 0..1 like Vulkan. The y flip reverses +// window-space winding, so front faces are clockwise. Only the final blit to the window +// (which shows row 0 at the bottom) flips. +// Requires ARB_bindless_texture (NVIDIA, AMD); without it init fails and the engine uses Vulkan. +#include "render/backend.h" +#include "render/screenshot.h" +#include "rhi/gl/gl_loader.h" +#include "core/log.h" +#include "platform/window.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace keel { +namespace { +// Every shader's source and the shared headers, embedded by the build (cmake/EmbedText.cmake). +const std::map& shaderSources() { + static const std::map sources = [] { + struct Entry { const char* name; const char* text; }; + static const Entry entries[] = { +#include "gl_sources.inc" + }; + std::map m; + for (const Entry& e : entries) m[e.name] = e.text; + return m; + }(); + return sources; +} + +// The source with #includes expanded and the GL preamble in place of its #version line. +std::string expand(std::string_view name, int depth = 0) { + const auto& sources = shaderSources(); + const auto it = sources.find(name); + if (it == sources.end() || depth > 8) { + log::error("OpenGL: shader source {} not found", name); + return {}; + } + std::string out; + std::string_view text = it->second; + while (!text.empty()) { + const size_t eol = text.find('\n'); + const std::string_view line = text.substr(0, eol); + text = eol == std::string_view::npos ? std::string_view{} : text.substr(eol + 1); + if (line.starts_with("#version")) { + out += "#version 460 core\n#extension GL_ARB_bindless_texture : require\n#define KEEL_GL 1\n"; + } else if (line.starts_with("#include \"")) { + const size_t a = line.find('"') + 1, b = line.find('"', a); + out += expand(line.substr(a, b - a), depth + 1); + } else { + out += line; + out += '\n'; + } + } + return out; +} + +GLuint compile(GLenum type, std::string_view name) { + const std::string src = expand(name); + const char* text = src.c_str(); + const GLuint s = gl::CreateShader(type); + gl::ShaderSource(s, 1, &text, nullptr); + gl::CompileShader(s); + GLint ok = 0; + gl::GetShaderiv(s, GL_COMPILE_STATUS, &ok); + if (!ok) { + char logText[4096]; + gl::GetShaderInfoLog(s, sizeof logText, nullptr, logText); + log::error("OpenGL: {} failed to compile:\n{}", name, logText); + gl::DeleteShader(s); + return 0; + } + return s; +} + +GLuint program(std::string_view vs, std::string_view fs) { + const GLuint v = compile(GL_VERTEX_SHADER, vs), f = fs.empty() ? 0 : compile(GL_FRAGMENT_SHADER, fs); + if (!v || (!fs.empty() && !f)) return 0; + const GLuint p = gl::CreateProgram(); + gl::AttachShader(p, v); + if (f) gl::AttachShader(p, f); + gl::LinkProgram(p); + gl::DeleteShader(v); + if (f) gl::DeleteShader(f); + GLint ok = 0; + gl::GetProgramiv(p, GL_LINK_STATUS, &ok); + if (!ok) { + char logText[4096]; + gl::GetProgramInfoLog(p, sizeof logText, nullptr, logText); + log::error("OpenGL: {} + {} failed to link:\n{}", vs, fs, logText); + gl::DeleteProgram(p); + return 0; + } + return p; +} + +void APIENTRY debugMessage(GLenum, GLenum type, GLuint, GLenum severity, GLsizei, const GLchar* message, const void*) { + if (severity == GL_DEBUG_SEVERITY_NOTIFICATION) return; + if (type == GL_DEBUG_TYPE_ERROR || severity == GL_DEBUG_SEVERITY_HIGH) log::error("[gl] {}", message); + else log::warn("[gl] {}", message); +} + +// Uniform blocks (std140) standing in for the push constants. +struct ModelPush { + glm::mat4 viewProj; + uint32_t textures, tint, material, drawIndex, firstJoint, pad[3]; +}; +struct BoxPush { + glm::mat4 viewProj; + gpu::Box box; +}; +struct HzbPush { + uint32_t depth, srcW, srcH, dstW, dstH, pad[3]; +}; +struct TonemapPush { + uint32_t scene; + float exposure; + uint32_t ao, bloom; + float bloomStrength, saturation, vignette, sharpen; + uint32_t depth, pad[3]; + glm::vec4 shafts, sunColor; +}; +struct SsaoPush { + uint32_t depth, w, h, outW, outH; + float radius, strength, pad; + glm::vec4 proj; +}; +struct SsaoBlurPush { + uint32_t ao, depth, w, h, outW, outH; + float pad0, pad1; + glm::vec4 proj; +}; +struct TaaPush { + glm::mat4 reproject; + uint32_t scene, history, depth, w, h, reset, ssr; + float pad1; +}; +struct AdaptPush { + uint32_t src, prev; + float dt, speed, key, minE, maxE, pad; +}; +struct SpritePush { + glm::mat4 viewProj; + gpu::Sprite sprite; +}; +struct VolumetricPush { + uint32_t depth, w, h, outW, outH; + float pad[3]; + glm::vec4 proj; +}; +struct SsrPush { + uint32_t depth, gbuf, scene, w, h, outW, outH; + float strength; + glm::vec4 proj; +}; +struct BloomPush { + uint32_t src, srcW, srcH, outW, outH; + uint32_t extra; // down: first level; up: radius (float bits) + uint32_t pad[2]; +}; + +// Storage buffer bindings (model.glsl, backend_gl.glsl). +enum Binding : GLuint { + B_VERTICES = 0, B_SKIN = 1, B_JOINTS = 2, B_DRAWS = 3, B_FRAME = 4, B_LIGHTS = 5, B_CELLS = 6, B_INDICES = 7, B_VIEWS = 8, + B_TEXTURES = 9, B_SHADOW_TEXTURES = 10, +}; + +class OpenGLBackend final : public RenderBackend { +public: + const char* name() const override { return "OpenGL 4.6"; } + GpuInfo gpuInfo() const override { return gpu_; } + GpuInfo gpu_; + + bool init(const Window& window, bool validation) override { + window_ = window.handle(); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 4); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 6); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, validation ? SDL_GL_CONTEXT_DEBUG_FLAG : 0); + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); + SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 0); // we render into our own targets + context_ = SDL_GL_CreateContext(window_); + if (!context_) { + log::warn("OpenGL 4.6 context: {}", SDL_GetError()); + return false; + } + SDL_GL_MakeCurrent(window_, context_); + if (!gl::load()) return false; + bool bindless = false; + GLint extensions = 0; + gl::GetIntegerv(GL_NUM_EXTENSIONS, &extensions); + for (GLint i = 0; i < extensions; ++i) + bindless |= std::string_view(reinterpret_cast(gl::GetStringi(GL_EXTENSIONS, GLuint(i)))) == "GL_ARB_bindless_texture"; + log::info("GPU: {} ({})", reinterpret_cast(gl::GetString(GL_RENDERER)), reinterpret_cast(gl::GetString(GL_VERSION))); + gpu_.name = reinterpret_cast(gl::GetString(GL_RENDERER)), gpu_.driver = reinterpret_cast(gl::GetString(GL_VERSION)); + if (!bindless) { + log::warn("OpenGL: GL_ARB_bindless_texture isn't supported by this driver"); + return false; + } + if (validation) { + gl::Enable(GL_DEBUG_OUTPUT); + gl::Enable(GL_DEBUG_OUTPUT_SYNCHRONOUS); + gl::DebugMessageCallback(debugMessage, nullptr); + } + gl::ClipControl(GL_LOWER_LEFT, GL_ZERO_TO_ONE); + gl::FrontFace(GL_CW); + gl::PixelStorei(GL_UNPACK_ALIGNMENT, 1); + gl::PixelStorei(GL_PACK_ALIGNMENT, 1); + + gl::CreateSamplers(1, &repeatSampler_); + gl::SamplerParameteri(repeatSampler_, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + gl::SamplerParameteri(repeatSampler_, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + gl::SamplerParameteri(repeatSampler_, GL_TEXTURE_WRAP_S, GL_REPEAT); + gl::SamplerParameteri(repeatSampler_, GL_TEXTURE_WRAP_T, GL_REPEAT); + gl::SamplerParameterf(repeatSampler_, GL_TEXTURE_MAX_ANISOTROPY, 16.0f); + gl::CreateSamplers(1, &clampSampler_); + gl::SamplerParameteri(clampSampler_, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + gl::SamplerParameteri(clampSampler_, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + gl::SamplerParameteri(clampSampler_, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + gl::SamplerParameteri(clampSampler_, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + gl::CreateSamplers(1, &compareSampler_); + gl::SamplerParameteri(compareSampler_, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + gl::SamplerParameteri(compareSampler_, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + gl::SamplerParameteri(compareSampler_, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + gl::SamplerParameteri(compareSampler_, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + gl::SamplerParameteri(compareSampler_, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE); + gl::SamplerParameteri(compareSampler_, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL); + + auto buffer = [](GLsizeiptr size) { + GLuint b; + gl::CreateBuffers(1, &b); + gl::NamedBufferStorage(b, size, nullptr, GL_DYNAMIC_STORAGE_BIT); + return b; + }; + textureTable_ = buffer(kMaxTextures * sizeof(GLuint64)); + shadowTable_ = buffer(kMaxTextures * sizeof(GLuint64)); + draws_ = buffer(kMaxDraws * sizeof(gpu::DrawData)); + joints_ = buffer(kMaxJointsPerFrame * sizeof(glm::mat4)); + frame_ = buffer(sizeof(gpu::FrameData)); + mirrorFrame_ = buffer(sizeof(gpu::FrameData)); + lights_ = buffer(kMaxLights * sizeof(gpu::Light)); + views_ = buffer(kMaxShadowViews * sizeof(gpu::ShadowView)); + cells_ = buffer(kClusterX * kClusterY * kClusterZ * sizeof(glm::uvec2)); + indices_ = buffer(kMaxLightIndices * sizeof(uint32_t)); + push_ = buffer(256); + empty_ = buffer(16); // bound where a shader declares a buffer the draw doesn't use + gl::CreateVertexArrays(1, &vao_); + + meshProgram_ = program("mesh.vert", "mesh.frag"); + skinnedProgram_ = program("skinned.vert", "mesh.frag"); + shadowMeshProgram_ = program("mesh.vert", ""); + shadowSkinnedProgram_ = program("skinned.vert", ""); + boxProgram_ = program("box.vert", "box.frag"); + tonemapProgram_ = program("fullscreen.vert", "tonemap.frag"); + skyProgram_ = program("sky.vert", "sky.frag"); + hzbProgram_ = program("fullscreen.vert", "hzb.frag"); + ssaoProgram_ = program("fullscreen.vert", "ssao.frag"); + ssaoBlurProgram_ = program("fullscreen.vert", "ssao_blur.frag"); + bloomDownProgram_ = program("fullscreen.vert", "bloom_down.frag"); + bloomUpProgram_ = program("fullscreen.vert", "bloom_up.frag"); + taaProgram_ = program("fullscreen.vert", "taa.frag"); + ssrProgram_ = program("fullscreen.vert", "ssr.frag"); + adaptProgram_ = program("fullscreen.vert", "adapt.frag"); + volumetricProgram_ = program("fullscreen.vert", "volumetric.frag"); + spriteProgram_ = program("sprite.vert", "sprite.frag"); + gl::CreateTextures(GL_TEXTURE_2D, 1, &hzbTex_); + gl::TextureStorage2D(hzbTex_, 1, GL_R32F, GLsizei(kHzbWidth), GLsizei(kHzbHeight)); + gl::CreateFramebuffers(1, &hzbFbo_); + gl::NamedFramebufferTexture(hzbFbo_, GL_COLOR_ATTACHMENT0, hzbTex_, 0); + gl::CreateBuffers(kHzbRing, hzbPbo_); + for (GLuint b : hzbPbo_) gl::NamedBufferStorage(b, kHzbWidth * kHzbHeight * 4, nullptr, GL_MAP_READ_BIT | GL_CLIENT_STORAGE_BIT); + if (!meshProgram_ || !skinnedProgram_ || !shadowMeshProgram_ || !shadowSkinnedProgram_ || !boxProgram_ || !tonemapProgram_ || !skyProgram_) + return false; + + sunShadow_ = depthTarget(kShadowSize, sunShadowFbo_); + atlas_ = depthTarget(kAtlasSize, atlasFbo_); + setHandles(kSunShadowSlot, sunShadow_, clampSampler_, true); + setHandles(kAtlasSlot, atlas_, clampSampler_, true); + + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGui::GetIO().IniFilename = nullptr; + ImGui::StyleColorsDark(); + imgui_ = ImGui_ImplSDL3_InitForOpenGL(window_, context_) && ImGui_ImplOpenGL3_Init("#version 460"); + return imgui_; + } + + void shutdown() override { + if (!context_) return; + if (imgui_) { + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplSDL3_Shutdown(); + ImGui::DestroyContext(); + imgui_ = false; + } + if (gl::DeleteBuffers) { + for (auto& [slot, t] : textures_) releaseTexture(t); + for (Model& m : models_) releaseModel(m); + releaseTargets(); + for (GLuint* t : {&sunShadow_.texture, &atlas_.texture}) gl::DeleteTextures(1, t); + for (GLuint* f : {&sunShadowFbo_, &atlasFbo_, &hzbFbo_}) gl::DeleteFramebuffers(1, f); + gl::DeleteTextures(1, &hzbTex_); + for (int i = 0; i < kHzbRing; ++i) { + if (hzbFence_[i]) gl::DeleteSync(hzbFence_[i]); + gl::DeleteBuffers(1, &hzbPbo_[i]); + } + for (GLuint b : {textureTable_, shadowTable_, draws_, joints_, frame_, mirrorFrame_, lights_, views_, cells_, indices_, push_, empty_}) + gl::DeleteBuffers(1, &b); + for (GLuint s : {repeatSampler_, clampSampler_, compareSampler_}) gl::DeleteSamplers(1, &s); + for (GLuint p : {meshProgram_, skinnedProgram_, shadowMeshProgram_, shadowSkinnedProgram_, boxProgram_, tonemapProgram_, skyProgram_, hzbProgram_, ssaoProgram_, ssaoBlurProgram_, ssrProgram_, adaptProgram_, volumetricProgram_, spriteProgram_, + bloomDownProgram_, bloomUpProgram_, taaProgram_}) + gl::DeleteProgram(p); + gl::DeleteVertexArrays(1, &vao_); + } + SDL_GL_DestroyContext(context_); + context_ = nullptr; + } + + void beginUi() override { + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplSDL3_NewFrame(); + ImGui::NewFrame(); + } + void setVsync(bool on) override { SDL_GL_SetSwapInterval(on ? 1 : 0); } + void onResize() override {} + + bool blockCompression() const override { return true; } // BPTC (BC7) is core since 4.2 + bool renderScale() const override { return true; } + + void createTexture(TextureId slot, uint32_t w, uint32_t h, bool srgb, const std::vector>& mips, + TexFormat format = TexFormat::RGBA8) override { + Texture t; + gl::CreateTextures(GL_TEXTURE_2D, 1, &t.texture); + if (format == TexFormat::BC7) { + const GLenum f = srgb ? 0x8E8D /* GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM */ : 0x8E8C /* GL_COMPRESSED_RGBA_BPTC_UNORM */; + gl::TextureStorage2D(t.texture, GLsizei(mips.size()), f, GLsizei(w), GLsizei(h)); + for (size_t level = 0; level < mips.size(); ++level) + gl::CompressedTextureSubImage2D(t.texture, GLint(level), 0, 0, GLsizei(std::max(1u, w >> level)), GLsizei(std::max(1u, h >> level)), f, + GLsizei(mips[level].size()), mips[level].data()); + } else { + gl::TextureStorage2D(t.texture, GLsizei(mips.size()), srgb ? GL_SRGB8_ALPHA8 : GL_RGBA8, GLsizei(w), GLsizei(h)); + for (size_t level = 0; level < mips.size(); ++level) + gl::TextureSubImage2D(t.texture, GLint(level), 0, 0, GLsizei(std::max(1u, w >> level)), GLsizei(std::max(1u, h >> level)), GL_RGBA, + GL_UNSIGNED_BYTE, mips[level].data()); + } + if (auto it = textures_.find(slot); it != textures_.end()) retiredTextures_.emplace_back(frameNumber_, it->second); // queued draws may use it + setHandles(slot, t, repeatSampler_, false); // like Vulkan: one repeating sampler for every texture + textures_[slot] = t; + } + + void uploadModel(uint32_t index, const KmdlModel& kmdl) override { + if (models_.size() <= index) models_.resize(index + 1); + releaseModel(models_[index]); // GL keeps it alive while queued draws still use it + Model m; + auto upload = [](const void* data, size_t size) { + GLuint b; + gl::CreateBuffers(1, &b); + gl::NamedBufferStorage(b, GLsizeiptr(size), data, 0); + return b; + }; + if (!kmdl.vertices.empty() && !kmdl.indices.empty()) { + m.vertices = upload(kmdl.vertices.data(), kmdl.vertices.size() * sizeof(KmdlVertex)); + m.indices = upload(kmdl.indices.data(), kmdl.indices.size() * sizeof(uint32_t)); + if (!kmdl.skin.empty()) m.skin = upload(kmdl.skin.data(), kmdl.skin.size() * sizeof(KmdlSkin)); + } + models_[index] = m; + } + + uint64_t uiTexture(TextureId slot) override { + const auto it = textures_.find(slot); + return it == textures_.end() ? 0 : uint64_t(it->second.texture); + } + + bool beginFrame(uint32_t& width, uint32_t& height) override { + int w = 0, h = 0; + if (SDL_GetWindowFlags(window_) & SDL_WINDOW_MINIMIZED) return false; + SDL_GetWindowSizeInPixels(window_, &w, &h); + if (w <= 0 || h <= 0) return false; + width = uint32_t(w); // (targets are sized to the scene in render) + height = uint32_t(h); + return true; + } + + void render(FramePacket& p) override; + + // Reads ring slot i's grid if the GPU is done with it (or, wait: before reusing the slot). + void collectHzb(int i, bool wait) { + if (!hzbFence_[i]) return; + const GLenum r = gl::ClientWaitSync(hzbFence_[i], wait ? GL_SYNC_FLUSH_COMMANDS_BIT : 0, wait ? 1'000'000'000 : 0); + if (r != GL_ALREADY_SIGNALED && r != GL_CONDITION_SATISFIED) return; + gl::DeleteSync(hzbFence_[i]); + hzbFence_[i] = nullptr; + if (hzbTags_[i] <= hzbLatest_.tag) return; + if (const float* d = static_cast(gl::MapNamedBufferRange(hzbPbo_[i], 0, kHzbWidth * kHzbHeight * 4, GL_MAP_READ_BIT))) { + hzbLatest_ = {hzbTags_[i], std::vector(d, d + kHzbWidth * kHzbHeight)}; + gl::UnmapNamedBuffer(hzbPbo_[i]); + } + } + + bool takeHzb(HzbReadback& out) override { + for (int i = 0; i < kHzbRing; ++i) collectHzb(i, false); + if (hzbLatest_.tag <= hzbTaken_) return false; + out = hzbLatest_; + hzbTaken_ = hzbLatest_.tag; + return true; + } + +private: + struct Texture { + GLuint texture = 0; + GLuint64 handle = 0, shadowHandle = 0; + }; + struct Model { + GLuint vertices = 0, indices = 0, skin = 0; + }; + + void setHandles(TextureId slot, Texture& t, GLuint sampler, bool depth) { + t.handle = gl::GetTextureSamplerHandleARB(t.texture, sampler); + gl::MakeTextureHandleResidentARB(t.handle); + gl::NamedBufferSubData(textureTable_, GLintptr(slot) * 8, 8, &t.handle); + if (depth) { + t.shadowHandle = gl::GetTextureSamplerHandleARB(t.texture, compareSampler_); + gl::MakeTextureHandleResidentARB(t.shadowHandle); + gl::NamedBufferSubData(shadowTable_, GLintptr(slot) * 8, 8, &t.shadowHandle); + } + } + void releaseTexture(Texture& t) { + if (t.handle) gl::MakeTextureHandleNonResidentARB(t.handle); + if (t.shadowHandle) gl::MakeTextureHandleNonResidentARB(t.shadowHandle); + if (t.texture) gl::DeleteTextures(1, &t.texture); + t = {}; + } + void releaseModel(Model& m) { + for (GLuint* b : {&m.vertices, &m.indices, &m.skin}) + if (*b) gl::DeleteBuffers(1, b); + m = {}; + } + Texture depthTarget(uint32_t size, GLuint& fbo) { + Texture t; + gl::CreateTextures(GL_TEXTURE_2D, 1, &t.texture); + gl::TextureStorage2D(t.texture, 1, GL_DEPTH_COMPONENT32F, GLsizei(size), GLsizei(size)); + gl::CreateFramebuffers(1, &fbo); + gl::NamedFramebufferTexture(fbo, GL_DEPTH_ATTACHMENT, t.texture, 0); + gl::NamedFramebufferDrawBuffer(fbo, GL_NONE); + return t; + } + void releaseTargets() { + releaseTexture(scene_); + releaseTexture(gbuf_); + releaseTexture(mirror_); + releaseTexture(mirrorGbuf_); + if (mirrorDepth_) gl::DeleteTextures(1, &mirrorDepth_); + if (mirrorFbo_) gl::DeleteFramebuffers(1, &mirrorFbo_); + mirrorDepth_ = mirrorFbo_ = 0; + releasePostTarget(ssr_); + for (PostTarget& t : adapt_) releasePostTarget(t); + releasePostTarget(ao_); + releasePostTarget(aoBlur_); + for (PostTarget& t : bloom_) releasePostTarget(t); + for (PostTarget& t : taa_) releasePostTarget(t); + if (depthTex_.handle) gl::MakeTextureHandleNonResidentARB(depthTex_.handle); + depthTex_ = {}; + if (sceneDepth_) gl::DeleteTextures(1, &sceneDepth_); + if (sceneFbo_) gl::DeleteFramebuffers(1, &sceneFbo_); + sceneDepth_ = sceneFbo_ = 0; + } + void ensureTargets(uint32_t w, uint32_t h) { + if (scene_.texture && w == targetW_ && h == targetH_) return; + releaseTargets(); + gl::CreateTextures(GL_TEXTURE_2D, 1, &scene_.texture); + gl::TextureStorage2D(scene_.texture, 1, GL_RGBA16F, GLsizei(w), GLsizei(h)); + gl::CreateTextures(GL_TEXTURE_2D, 1, &sceneDepth_); + gl::TextureStorage2D(sceneDepth_, 1, GL_DEPTH_COMPONENT32F, GLsizei(w), GLsizei(h)); + gl::CreateFramebuffers(1, &sceneFbo_); + gl::NamedFramebufferTexture(sceneFbo_, GL_COLOR_ATTACHMENT0, scene_.texture, 0); + gl::NamedFramebufferTexture(sceneFbo_, GL_DEPTH_ATTACHMENT, sceneDepth_, 0); + gl::CreateTextures(GL_TEXTURE_2D, 1, &gbuf_.texture); // the G-buffer: view normal, roughness, reflection weight + gl::TextureStorage2D(gbuf_.texture, 1, GL_RGBA16F, GLsizei(w), GLsizei(h)); + gl::NamedFramebufferTexture(sceneFbo_, GL_COLOR_ATTACHMENT1, gbuf_.texture, 0); + const GLenum bufs[2] = {GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1}; + gl::NamedFramebufferDrawBuffers(sceneFbo_, 2, bufs); + setHandles(kGbufSlot, gbuf_, clampSampler_, false); + if (gl::CheckNamedFramebufferStatus(sceneFbo_, GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) log::error("OpenGL: scene framebuffer incomplete"); + setHandles(kSceneSlot, scene_, clampSampler_, false); + gl::CreateTextures(GL_TEXTURE_2D, 1, &mirror_.texture); + gl::TextureStorage2D(mirror_.texture, 1, GL_RGBA16F, GLsizei(w), GLsizei(h)); + gl::CreateTextures(GL_TEXTURE_2D, 1, &mirrorGbuf_.texture); + gl::TextureStorage2D(mirrorGbuf_.texture, 1, GL_RGBA16F, GLsizei(w), GLsizei(h)); + gl::CreateTextures(GL_TEXTURE_2D, 1, &mirrorDepth_); + gl::TextureStorage2D(mirrorDepth_, 1, GL_DEPTH_COMPONENT32F, GLsizei(w), GLsizei(h)); + gl::CreateFramebuffers(1, &mirrorFbo_); + gl::NamedFramebufferTexture(mirrorFbo_, GL_COLOR_ATTACHMENT0, mirror_.texture, 0); + gl::NamedFramebufferTexture(mirrorFbo_, GL_COLOR_ATTACHMENT1, mirrorGbuf_.texture, 0); + gl::NamedFramebufferTexture(mirrorFbo_, GL_DEPTH_ATTACHMENT, mirrorDepth_, 0); + gl::NamedFramebufferDrawBuffers(mirrorFbo_, 2, bufs); + setHandles(kMirrorSlot, mirror_, clampSampler_, false); + depthTex_.texture = sceneDepth_; + setHandles(kDepthSlot, depthTex_, clampSampler_, false); + ao_ = makePostTarget(kAoSlot, GL_R8, std::max(1u, w / 2), std::max(1u, h / 2)); + aoBlur_ = makePostTarget(kAoBlurSlot, GL_R8, std::max(1u, w / 2), std::max(1u, h / 2)); + ssr_ = makePostTarget(kSsrSlot, GL_RGBA16F, std::max(1u, w / 2), std::max(1u, h / 2)); + for (uint32_t i = 0; i < 2; ++i) adapt_[i] = makePostTarget(kAdaptSlot + i, GL_RGBA16F, 1, 1); + adaptValid_ = false; + for (uint32_t i = 0; i < 2; ++i) taa_[i] = makePostTarget(kTaaSlot + i, GL_RGBA16F, w, h); + for (uint32_t i = 0; i < kBloomLevels; ++i) + bloom_[i] = makePostTarget(kBloomSlot + i, GL_RGBA16F, std::max(1u, w >> (i + 1)), std::max(1u, h >> (i + 1))); + targetW_ = w; + targetH_ = h; + } + template void push(const T& data) { gl::NamedBufferSubData(push_, 0, sizeof data, &data); } + void drawMeshes(const std::vector& draws, const glm::mat4& viewProj, bool shadow, GLuint mesh, GLuint skinned); + void shadowPasses(GLuint fbo, uint32_t size, const std::vector& passes); + + SDL_Window* window_ = nullptr; + SDL_GLContext context_ = nullptr; + bool imgui_ = false; + GLuint repeatSampler_ = 0, clampSampler_ = 0, compareSampler_ = 0; + GLuint mirrorFrame_ = 0; // the mirror's pass's frame data + GLuint textureTable_ = 0, shadowTable_ = 0, draws_ = 0, joints_ = 0, frame_ = 0, lights_ = 0, views_ = 0, cells_ = 0, indices_ = 0; + GLuint push_ = 0, empty_ = 0, vao_ = 0; + GLuint meshProgram_ = 0, skinnedProgram_ = 0, shadowMeshProgram_ = 0, shadowSkinnedProgram_ = 0, boxProgram_ = 0, tonemapProgram_ = 0, skyProgram_ = 0, hzbProgram_ = 0; + GLuint ssaoProgram_ = 0, ssaoBlurProgram_ = 0, bloomDownProgram_ = 0, bloomUpProgram_ = 0, taaProgram_ = 0, ssrProgram_ = 0, adaptProgram_ = 0, volumetricProgram_ = 0, spriteProgram_ = 0; + uint32_t adaptCur_ = 0; + bool adaptValid_ = false; + uint32_t taaCur_ = 0; + uint64_t frameNumber_ = 0; + std::vector> retiredTextures_; + uint32_t shadowMapSize_ = kShadowSize; + // Post-processing targets: a texture in the table and a framebuffer to draw into it. + struct PostTarget { + Texture tex; + GLuint fbo = 0; + uint32_t w = 0, h = 0; + }; + PostTarget ao_, aoBlur_, bloom_[kBloomLevels], taa_[2], ssr_, adapt_[2]; + PostTarget makePostTarget(TextureId slot, GLenum format, uint32_t w, uint32_t h) { + PostTarget t; + t.w = w, t.h = h; + gl::CreateTextures(GL_TEXTURE_2D, 1, &t.tex.texture); + gl::TextureStorage2D(t.tex.texture, 1, format, GLsizei(w), GLsizei(h)); + gl::CreateFramebuffers(1, &t.fbo); + gl::NamedFramebufferTexture(t.fbo, GL_COLOR_ATTACHMENT0, t.tex.texture, 0); + setHandles(slot, t.tex, clampSampler_, false); + return t; + } + void releasePostTarget(PostTarget& t) { + releaseTexture(t.tex); + if (t.fbo) gl::DeleteFramebuffers(1, &t.fbo); + t = {}; + } + // Occlusion grid: drawn, read into a pixel-pack buffer, picked up once its fence passes. + static constexpr int kHzbRing = 3; + GLuint hzbTex_ = 0, hzbFbo_ = 0, hzbPbo_[kHzbRing] = {}; + GLsync hzbFence_[kHzbRing] = {}; + uint64_t hzbTags_[kHzbRing] = {}; + int hzbNext_ = 0; + uint64_t hzbTaken_ = 0; + HzbReadback hzbLatest_; + Texture depthTex_; // the scene depth's handle (texture owned by sceneDepth_) + Texture sunShadow_, atlas_, scene_, gbuf_; + // Mirrors: the reflected scene (and its G-buffer and depth), the scene's size. + Texture mirror_, mirrorGbuf_; + GLuint mirrorDepth_ = 0, mirrorFbo_ = 0; + bool mirrors() const override { return true; } + GLuint sunShadowFbo_ = 0, atlasFbo_ = 0, sceneFbo_ = 0, sceneDepth_ = 0; + uint32_t targetW_ = 0, targetH_ = 0; + std::map textures_; + std::vector models_; +}; + +void OpenGLBackend::drawMeshes(const std::vector& draws, const glm::mat4& viewProj, bool shadow, GLuint mesh, GLuint skinned) { + GLuint bound = 0; + uint32_t boundModel = UINT32_MAX; + ModelPush mp{}; + mp.viewProj = viewProj; + for (const MeshDraw& d : draws) { + const Model& m = models_[d.model]; + if (!m.indices) continue; + const bool isSkinned = d.firstJoint != UINT32_MAX; + const GLuint want = isSkinned ? skinned : mesh; + if (want != bound) { + gl::UseProgram(want); + bound = want; + } + if (d.model != boundModel) { + gl::VertexArrayElementBuffer(vao_, m.indices); + gl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, B_VERTICES, m.vertices); + gl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, B_SKIN, m.skin ? m.skin : empty_); + boundModel = d.model; + } + mp.textures = d.textures; + mp.tint = d.tint; + mp.material = shadow ? 0 : d.material; + mp.drawIndex = d.draw; + mp.firstJoint = isSkinned ? d.firstJoint : 0; + push(mp); + gl::DrawElements(GL_TRIANGLES, GLsizei(d.indexCount), GL_UNSIGNED_INT, reinterpret_cast(uintptr_t(d.firstIndex) * 4)); + } +} + +void OpenGLBackend::shadowPasses(GLuint fbo, uint32_t size, const std::vector& passes) { + gl::BindFramebuffer(GL_FRAMEBUFFER, fbo); + gl::DepthMask(GL_TRUE); + gl::Disable(GL_SCISSOR_TEST); + const GLfloat one = 1.0f; + gl::ClearNamedFramebufferfv(fbo, GL_DEPTH, 0, &one); + gl::Enable(GL_SCISSOR_TEST); + gl::Enable(GL_POLYGON_OFFSET_FILL); + gl::PolygonOffset(2.5f, 2.0f); // same as Vulkan's depth bias (slope, constant) + for (const ShadowPass& pass : passes) { + gl::Viewport(pass.rect.x, pass.rect.y, pass.rect.z, pass.rect.w); + gl::Scissor(pass.rect.x, pass.rect.y, pass.rect.z, pass.rect.w); + drawMeshes(*pass.draws, pass.viewProj, true, shadowMeshProgram_, shadowSkinnedProgram_); + } + gl::Disable(GL_POLYGON_OFFSET_FILL); + gl::Disable(GL_SCISSOR_TEST); + (void)size; +} + +void OpenGLBackend::render(FramePacket& p) { + ++frameNumber_; + ensureTargets(p.width, p.height); // the scene's size (render scale), not the window's + std::erase_if(retiredTextures_, [&](auto& r) { + if (frameNumber_ - r.first <= 3) return false; + releaseTexture(r.second); + return true; + }); + gl::ClipControl(GL_LOWER_LEFT, GL_ZERO_TO_ONE); // ImGui may have touched state; ours is set fresh each frame + gl::FrontFace(GL_CW); + auto upload = [](GLuint buffer, const void* data, size_t size) { + if (size) gl::NamedBufferSubData(buffer, 0, GLsizeiptr(size), data); + }; + upload(draws_, p.draws.data(), p.draws.size() * sizeof(gpu::DrawData)); + upload(joints_, p.joints.data(), p.joints.size() * sizeof(glm::mat4)); + upload(frame_, &p.frame, sizeof p.frame); + upload(lights_, p.lights.data(), p.lights.size() * sizeof(gpu::Light)); + upload(views_, p.views.data(), p.views.size() * sizeof(gpu::ShadowView)); + upload(cells_, p.clusters->cells.data(), p.clusters->cells.size() * sizeof(glm::uvec2)); + upload(indices_, p.clusters->indices.data(), p.clusters->indices.size() * sizeof(uint32_t)); + const std::pair bindings[] = {{B_DRAWS, draws_}, {B_JOINTS, joints_}, {B_FRAME, frame_}, {B_LIGHTS, lights_}, + {B_CELLS, cells_}, {B_INDICES, indices_}, {B_VIEWS, views_}, + {B_TEXTURES, textureTable_}, {B_SHADOW_TEXTURES, shadowTable_}}; + for (auto [binding, buffer] : bindings) gl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, binding, buffer); + gl::BindBufferBase(GL_UNIFORM_BUFFER, 0, push_); + gl::BindVertexArray(vao_); + gl::Disable(GL_CULL_FACE); + gl::Disable(GL_BLEND); + gl::Enable(GL_DEPTH_TEST); + gl::DepthFunc(GL_LESS); + + if (p.shadowSize != shadowMapSize_) { // r_shadowres changed + releaseTexture(sunShadow_); + gl::DeleteFramebuffers(1, &sunShadowFbo_); + sunShadow_ = depthTarget(p.shadowSize, sunShadowFbo_); + setHandles(kSunShadowSlot, sunShadow_, clampSampler_, true); + shadowMapSize_ = p.shadowSize; + } + shadowPasses(sunShadowFbo_, p.shadowSize, p.sunPasses); // always: clears it when there's no sun + if (!p.atlasPasses.empty()) shadowPasses(atlasFbo_, kAtlasSize, p.atlasPasses); + + // ── Mirror: the scene from the reflected eye, cut at the mirror, with its own frame data ── + if (p.mirror) { + gpu::FrameData mf = p.mirrorFrame; + mf.gi = mf.giOrigin = glm::vec4(0.0f); // (no GI probes here) + upload(mirrorFrame_, &mf, sizeof mf); + gl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, B_FRAME, mirrorFrame_); + gl::BindFramebuffer(GL_FRAMEBUFFER, mirrorFbo_); + gl::Viewport(0, 0, GLsizei(p.width), GLsizei(p.height)); + gl::DepthMask(GL_TRUE); + const GLfloat mclear[4] = {p.clearColor.r, p.clearColor.g, p.clearColor.b, 1.0f}, mone = 1.0f; + gl::ClearNamedFramebufferfv(mirrorFbo_, GL_COLOR, 0, mclear); + gl::ClearNamedFramebufferfv(mirrorFbo_, GL_DEPTH, 0, &mone); + gl::Enable(GL_SCISSOR_TEST); // only where the mirror is on screen + gl::Scissor(p.mirrorRect.x, p.mirrorRect.y, p.mirrorRect.z, p.mirrorRect.w); + drawMeshes(p.mirrorOpaque, p.mirrorViewProj, false, meshProgram_, skinnedProgram_); + if (p.frame.sky.x > 0.5f) { + gl::UseProgram(skyProgram_); + push(gpu::SkyPush{p.mirrorInvViewProj, p.frame.sunDir, p.frame.sunColor, p.frame.sky, glm::vec4(p.skyCloud, p.skyPhotoSunZ, p.frame.weather.z, p.frame.weather.w)}); + gl::DepthFunc(GL_LEQUAL); + gl::DepthMask(GL_FALSE); + gl::DrawArrays(GL_TRIANGLES, 0, 3); + gl::DepthMask(GL_TRUE); + gl::DepthFunc(GL_LESS); + } + if (!p.mirrorTranslucent.empty()) { + gl::Enable(GL_BLEND); + gl::BlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); + gl::DepthMask(GL_FALSE); + drawMeshes(p.mirrorTranslucent, p.mirrorViewProj, false, meshProgram_, skinnedProgram_); + gl::DepthMask(GL_TRUE); + gl::Disable(GL_BLEND); + } + gl::Disable(GL_SCISSOR_TEST); + gl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, B_FRAME, frame_); + } + + // ── Scene pass (HDR) ── + gl::BindFramebuffer(GL_FRAMEBUFFER, sceneFbo_); + gl::Viewport(0, 0, GLsizei(p.width), GLsizei(p.height)); + gl::DepthMask(GL_TRUE); + const GLfloat clear[4] = {p.clearColor.r, p.clearColor.g, p.clearColor.b, 1.0f}, one = 1.0f; + const GLfloat noReflection[4] = {0.5f, 0.5f, 1.0f, 0.0f}; + gl::ClearNamedFramebufferfv(sceneFbo_, GL_COLOR, 0, clear); + gl::ClearNamedFramebufferfv(sceneFbo_, GL_COLOR, 1, noReflection); + gl::ClearNamedFramebufferfv(sceneFbo_, GL_DEPTH, 0, &one); + // Only opaque meshes write the G-buffer (attachment 1). + auto gbufWrites = [](bool on) { gl::ColorMaski(1, on, on, on, on); }; + gbufWrites(false); + gl::UseProgram(boxProgram_); + for (const gpu::Box& b : p.boxes) { + push(BoxPush{p.viewProj, b}); + gl::DrawArrays(GL_TRIANGLES, 0, 36); + } + gbufWrites(true); + drawMeshes(p.opaque, p.viewProj, false, meshProgram_, skinnedProgram_); + gbufWrites(false); + if (p.frame.sky.x > 0.5f) { // where nothing was drawn + gl::UseProgram(skyProgram_); + push(gpu::SkyPush{p.invViewProj, p.frame.sunDir, p.frame.sunColor, p.frame.sky, glm::vec4(p.skyCloud, p.skyPhotoSunZ, p.frame.weather.z, p.frame.weather.w)}); + gl::DepthFunc(GL_LEQUAL); + gl::DepthMask(GL_FALSE); + gl::DrawArrays(GL_TRIANGLES, 0, 3); + gl::DepthMask(GL_TRUE); + gl::DepthFunc(GL_LESS); + } + if (!p.translucent.empty()) { // back to front, blended, no depth writes + gl::Enable(GL_BLEND); + gl::BlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); + gl::DepthMask(GL_FALSE); + drawMeshes(p.translucent, p.viewProj, false, meshProgram_, skinnedProgram_); + gl::DepthMask(GL_TRUE); + gl::Disable(GL_BLEND); + } + if (!p.sprites.empty() || !p.glowSprites.empty()) { // particles: depth-tested, not written; blended, then added on + gbufWrites(false); + gl::UseProgram(spriteProgram_); + gl::Enable(GL_BLEND); + gl::DepthMask(GL_FALSE); + gl::BlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); + for (const gpu::Sprite& sp : p.sprites) push(SpritePush{p.viewProj, sp}), gl::DrawArrays(GL_TRIANGLES, 0, 6); + gl::BlendFuncSeparate(GL_SRC_ALPHA, GL_ONE, GL_ONE, GL_ONE); + for (const gpu::Sprite& sp : p.glowSprites) push(SpritePush{p.viewProj, sp}), gl::DrawArrays(GL_TRIANGLES, 0, 6); + gl::DepthMask(GL_TRUE); + gl::Disable(GL_BLEND); + gbufWrites(true); + } + gbufWrites(true); + + // ── Occlusion grid: the depth (only opaque things write it) shrunk and read back ── + if (p.hzbTag) { + gl::BindFramebuffer(GL_FRAMEBUFFER, hzbFbo_); + gl::Viewport(0, 0, GLsizei(kHzbWidth), GLsizei(kHzbHeight)); + gl::Disable(GL_DEPTH_TEST); + gl::UseProgram(hzbProgram_); + push(HzbPush{kDepthSlot, p.width, p.height, kHzbWidth, kHzbHeight, {}}); + gl::DrawArrays(GL_TRIANGLES, 0, 3); + const int i = hzbNext_; + hzbNext_ = (hzbNext_ + 1) % kHzbRing; + collectHzb(i, true); // the CPU ran ahead (no vsync): take that grid before the slot is reused + gl::BindBuffer(GL_PIXEL_PACK_BUFFER, hzbPbo_[i]); + gl::ReadBuffer(GL_COLOR_ATTACHMENT0); + gl::ReadPixels(0, 0, GLsizei(kHzbWidth), GLsizei(kHzbHeight), GL_RED, GL_FLOAT, nullptr); + gl::BindBuffer(GL_PIXEL_PACK_BUFFER, 0); + hzbFence_[i] = gl::FenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); + hzbTags_[i] = p.hzbTag; + gl::Enable(GL_DEPTH_TEST); + } + + // ── Post: ambient occlusion, bloom ── + gl::Disable(GL_DEPTH_TEST); + auto pass = [&](const PostTarget& t, GLuint prog, const auto& data, bool additive) { + gl::BindFramebuffer(GL_FRAMEBUFFER, t.fbo); + gl::Viewport(0, 0, GLsizei(t.w), GLsizei(t.h)); + gl::UseProgram(prog); + push(data); + if (additive) { + gl::Enable(GL_BLEND); + gl::BlendFuncSeparate(GL_ONE, GL_ONE, GL_ONE, GL_ONE); + } + gl::DrawArrays(GL_TRIANGLES, 0, 3); + if (additive) gl::Disable(GL_BLEND); + }; + const bool ao = p.post.aoStrength > 0.0f, bloom = p.post.bloomStrength > 0.0f, reflections = p.post.ssr > 0.0f, + volumetric = p.frame.volumetric.x > 0.0f, // the sun and the dynamic lights + ssr = reflections || volumetric; // (the half-size target both go to) + if (reflections) pass(ssr_, ssrProgram_, SsrPush{kDepthSlot, kGbufSlot, kSceneSlot, p.width, p.height, ssr_.w, ssr_.h, p.post.ssr, p.post.proj}, false); + if (volumetric) // sunlight in the air, onto the same target + pass(ssr_, volumetricProgram_, VolumetricPush{kDepthSlot, p.width, p.height, ssr_.w, ssr_.h, {}, p.post.proj}, reflections); + TextureId resolved = kSceneSlot; + if (p.post.taa) { + taaCur_ ^= 1u; + pass(taa_[taaCur_], taaProgram_, + TaaPush{p.post.reproject, kSceneSlot, kTaaSlot + (taaCur_ ^ 1u), kDepthSlot, p.width, p.height, p.post.taaReset ? 1u : 0u, ssr ? kSsrSlot : 0u, 0}, false); + resolved = kTaaSlot + taaCur_; + } + if (ao) { + pass(ao_, ssaoProgram_, SsaoPush{kDepthSlot, p.width, p.height, ao_.w, ao_.h, p.post.aoRadius, p.post.aoStrength, 0, p.post.proj}, false); + pass(aoBlur_, ssaoBlurProgram_, SsaoBlurPush{kAoSlot, kDepthSlot, p.width, p.height, aoBlur_.w, aoBlur_.h, 0, 0, p.post.proj}, false); + } + if (bloom) { + for (uint32_t i = 0; i < kBloomLevels; ++i) { + const uint32_t sw = i == 0 ? p.width : bloom_[i - 1].w, sh = i == 0 ? p.height : bloom_[i - 1].h; + pass(bloom_[i], bloomDownProgram_, BloomPush{i == 0 ? resolved : kBloomSlot + i - 1, sw, sh, bloom_[i].w, bloom_[i].h, i == 0 ? 1u : 0u, {}}, false); + } + const float radius = 1.0f; + uint32_t radiusBits; + std::memcpy(&radiusBits, &radius, 4); + for (uint32_t i = kBloomLevels - 1; i > 0; --i) + pass(bloom_[i - 1], bloomUpProgram_, BloomPush{kBloomSlot + i, bloom_[i].w, bloom_[i].h, bloom_[i - 1].w, bloom_[i - 1].h, radiusBits, {}}, true); + } + + if (p.post.adapt) { // eye adaptation from the smallest bloom level + adaptCur_ ^= 1u; + pass(adapt_[adaptCur_], adaptProgram_, + AdaptPush{kBloomSlot + kBloomLevels - 1, p.post.adaptReset || !adaptValid_ ? 0u : kAdaptSlot + (adaptCur_ ^ 1u), p.post.adaptDt, 1.5f, + p.post.adaptKey, 0.6f, 1.8f, 0}, + false); + adaptValid_ = true; + } + + // ── Tonemap to the window, then UI ── + gl::BindFramebuffer(GL_FRAMEBUFFER, 0); + gl::Viewport(0, 0, GLsizei(p.outWidth), GLsizei(p.outHeight)); + gl::Disable(GL_DEPTH_TEST); + gl::UseProgram(tonemapProgram_); + push(TonemapPush{resolved, p.exposure, ao ? kAoBlurSlot : 0u, bloom ? kBloomSlot : 0u, p.post.bloomStrength, p.post.saturation, p.post.vignette, p.post.taa ? p.post.sharpen : 0.0f, kDepthSlot, {ssr && !p.post.taa ? kSsrSlot : 0u, p.post.adapt ? kAdaptSlot + adaptCur_ : 0u, 0}, p.post.shafts, p.post.sunColor}); + gl::DrawArrays(GL_TRIANGLES, 0, 3); + gl::BindVertexArray(0); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + + if (!p.screenshot.empty()) { + std::vector rows(size_t(p.outWidth) * p.outHeight * 4), px(rows.size()); + gl::ReadBuffer(GL_BACK); + gl::ReadPixels(0, 0, GLsizei(p.outWidth), GLsizei(p.outHeight), GL_RGBA, GL_UNSIGNED_BYTE, rows.data()); + const size_t stride = size_t(p.outWidth) * 4; // the window's row 0 is its bottom + for (uint32_t y = 0; y < p.outHeight; ++y) std::memcpy(&px[y * stride], &rows[(p.outHeight - 1 - y) * stride], stride); + saveScreenshot(p.screenshot, std::move(px), p.outWidth, p.outHeight); + } + SDL_GL_SwapWindow(window_); +} +} // namespace + +std::unique_ptr createOpenGLBackend() { return std::make_unique(); } + +} // namespace keel diff --git a/engine/rhi/gl/gl_legacy.cpp b/engine/rhi/gl/gl_legacy.cpp new file mode 100644 index 0000000..adf0d11 --- /dev/null +++ b/engine/rhi/gl/gl_legacy.cpp @@ -0,0 +1,820 @@ +// The fallback renderer for old GPUs: OpenGL 4.0 or 3.1, GLSL 1.40, nothing newer — bound +// textures, vertex attributes, one uniform block for the frame, a texture buffer for skinning. +// Two tiers, cut down from the main renderer: +// 4.0 lightmaps, sun with cascaded shadows (PCF), HDR sky, fog, normal maps, GGX highlights, +// sky reflections, the 16 nearest dynamic lights (no shadows) +// 3.1 lightmaps, sun with shadows (one tap), sky, fog; flat surfaces, no highlights or lights +// Neither has clustered lights, light shadows or occlusion culling. Depth is GL's -1..1 here +// (no glClipControl), so vertex shaders turn the 0..1 matrices' z into it. +#include "render/backend.h" +#include "core/log.h" +#include "platform/window.h" +#include "render/screenshot.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace keel { +namespace { + +// Entry points from OpenGL 3.1 (no DSA, no sampler objects, no storage buffers). +#define KEEL_GLX_FUNCTIONS(X) \ + X(const GLubyte*, GetString, (GLenum)) \ + X(void, GetIntegerv, (GLenum, GLint*)) \ + X(void, Enable, (GLenum)) \ + X(void, Disable, (GLenum)) \ + X(void, Viewport, (GLint, GLint, GLsizei, GLsizei)) \ + X(void, Clear, (GLbitfield)) \ + X(void, ClearColor, (GLfloat, GLfloat, GLfloat, GLfloat)) \ + X(void, ClearDepth, (GLdouble)) \ + X(void, DepthFunc, (GLenum)) \ + X(void, DepthMask, (GLboolean)) \ + X(void, BlendFuncSeparate, (GLenum, GLenum, GLenum, GLenum)) \ + X(void, PolygonOffset, (GLfloat, GLfloat)) \ + X(void, PixelStorei, (GLenum, GLint)) \ + X(void, DrawBuffer, (GLenum)) \ + X(void, ReadBuffer, (GLenum)) \ + X(void, ReadPixels, (GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, void*)) \ + X(void, GenBuffers, (GLsizei, GLuint*)) \ + X(void, DeleteBuffers, (GLsizei, const GLuint*)) \ + X(void, BindBuffer, (GLenum, GLuint)) \ + X(void, BindBufferBase, (GLenum, GLuint, GLuint)) \ + X(void, BufferData, (GLenum, GLsizeiptr, const void*, GLenum)) \ + X(void, BufferSubData, (GLenum, GLintptr, GLsizeiptr, const void*)) \ + X(void, GenTextures, (GLsizei, GLuint*)) \ + X(void, DeleteTextures, (GLsizei, const GLuint*)) \ + X(void, BindTexture, (GLenum, GLuint)) \ + X(void, ActiveTexture, (GLenum)) \ + X(void, TexImage2D, (GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum, GLenum, const void*)) \ + X(void, TexParameteri, (GLenum, GLenum, GLint)) \ + X(void, TexBuffer, (GLenum, GLenum, GLuint)) \ + X(void, GenFramebuffers, (GLsizei, GLuint*)) \ + X(void, DeleteFramebuffers, (GLsizei, const GLuint*)) \ + X(void, BindFramebuffer, (GLenum, GLuint)) \ + X(void, FramebufferTexture2D, (GLenum, GLenum, GLenum, GLuint, GLint)) \ + X(GLenum, CheckFramebufferStatus, (GLenum)) \ + X(void, GenVertexArrays, (GLsizei, GLuint*)) \ + X(void, DeleteVertexArrays, (GLsizei, const GLuint*)) \ + X(void, BindVertexArray, (GLuint)) \ + X(void, EnableVertexAttribArray, (GLuint)) \ + X(void, VertexAttribPointer, (GLuint, GLint, GLenum, GLboolean, GLsizei, const void*)) \ + X(void, VertexAttribIPointer, (GLuint, GLint, GLenum, GLsizei, const void*)) \ + X(void, VertexAttribI4ui, (GLuint, GLuint, GLuint, GLuint, GLuint)) \ + X(void, VertexAttrib4f, (GLuint, GLfloat, GLfloat, GLfloat, GLfloat)) \ + X(void, DrawElements, (GLenum, GLsizei, GLenum, const void*)) \ + X(void, DrawArrays, (GLenum, GLint, GLsizei)) \ + X(GLuint, CreateShader, (GLenum)) \ + X(void, ShaderSource, (GLuint, GLsizei, const GLchar* const*, const GLint*)) \ + X(void, CompileShader, (GLuint)) \ + X(void, GetShaderiv, (GLuint, GLenum, GLint*)) \ + X(void, GetShaderInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*)) \ + X(void, DeleteShader, (GLuint)) \ + X(GLuint, CreateProgram, (void)) \ + X(void, AttachShader, (GLuint, GLuint)) \ + X(void, BindAttribLocation, (GLuint, GLuint, const GLchar*)) \ + X(void, BindFragDataLocation, (GLuint, GLuint, const GLchar*)) \ + X(void, LinkProgram, (GLuint)) \ + X(void, GetProgramiv, (GLuint, GLenum, GLint*)) \ + X(void, GetProgramInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*)) \ + X(void, DeleteProgram, (GLuint)) \ + X(void, UseProgram, (GLuint)) \ + X(GLint, GetUniformLocation, (GLuint, const GLchar*)) \ + X(GLuint, GetUniformBlockIndex, (GLuint, const GLchar*)) \ + X(void, UniformBlockBinding, (GLuint, GLuint, GLuint)) \ + X(void, Uniform1i, (GLint, GLint)) \ + X(void, Uniform4fv, (GLint, GLsizei, const GLfloat*)) \ + X(void, UniformMatrix4fv, (GLint, GLsizei, GLboolean, const GLfloat*)) + +namespace glx { +#define KEEL_GLX_DECLARE(ret, name, params) using name##_t = ret(APIENTRY*) params; name##_t name = nullptr; +KEEL_GLX_FUNCTIONS(KEEL_GLX_DECLARE) +#undef KEEL_GLX_DECLARE +bool load() { + std::string missing; +#define KEEL_GLX_LOAD(ret, name, params) \ + name = reinterpret_cast(SDL_GL_GetProcAddress("gl" #name)); \ + if (!name) missing += " gl" #name; + KEEL_GLX_FUNCTIONS(KEEL_GLX_LOAD) +#undef KEEL_GLX_LOAD + if (!missing.empty()) log::warn("OpenGL (legacy): missing functions:{}", missing); + return missing.empty(); +} +} // namespace glx + +// ── Shaders (GLSL 1.40; TIER is 1 for GL 3.1, 2 for 4.0) ── +const char* kFrameBlock = R"( +layout(std140) uniform Frame { // (GLSL 1.40: no instance name, so the members say F_) + mat4 F_cascades[4]; + vec4 F_splits, F_cascadeTexel, F_sunDir, F_sunColor, F_shadow, F_camPos, F_sky, F_fog, F_lightCount; + vec4 F_lights[64]; // 16 × (pos radius, colour, spot dir cos-outer, cos-inner) +}; +)"; + +const char* kMeshVert = R"( +in vec3 a_pos; in vec3 a_normal; in vec2 a_uv; in vec2 a_lmuv; in uvec4 a_joints; in vec4 a_weights; +uniform mat4 u_viewProj, u_model; +uniform int u_skinned, u_firstJoint; +uniform samplerBuffer u_joints; +out vec3 v_normal, v_world; out vec2 v_uv, v_lmuv; +mat4 joint(uint j) { + int b = (u_firstJoint + int(j)) * 4; + return mat4(texelFetch(u_joints, b), texelFetch(u_joints, b + 1), texelFetch(u_joints, b + 2), texelFetch(u_joints, b + 3)); +} +void main() { + mat4 m = u_model; + if (u_skinned != 0) + m = joint(a_joints.x) * a_weights.x + joint(a_joints.y) * a_weights.y + joint(a_joints.z) * a_weights.z + joint(a_joints.w) * a_weights.w; + vec4 w = m * vec4(a_pos, 1.0); + v_world = w.xyz; + v_normal = mat3(m) * a_normal; + v_uv = a_uv; + v_lmuv = a_lmuv; + gl_Position = u_viewProj * w; + gl_Position.z = gl_Position.z * 2.0 - gl_Position.w; // 0..1 → GL's -1..1 +} +)"; + +const char* kDepthFrag = R"( +out vec4 o_color; +void main() { o_color = vec4(1.0); } +)"; + +const char* kMeshFrag = R"( +in vec3 v_normal, v_world; in vec2 v_uv, v_lmuv; +uniform sampler2D u_albedo, u_lightmap, u_material, u_skyVis, u_sky; +uniform sampler2DShadow u_sunShadow; +uniform vec4 u_tint, u_params, u_plane, u_flags; // flags: x lightmap, y material, z sky visibility +uniform vec4 u_cube[6]; +out vec4 o_color; + +vec3 ambientCube(vec3 n) { + vec3 w = n * n; + return w.x * (n.x > 0.0 ? u_cube[0].rgb : u_cube[1].rgb) + w.y * (n.y > 0.0 ? u_cube[2].rgb : u_cube[3].rgb) + + w.z * (n.z > 0.0 ? u_cube[4].rgb : u_cube[5].rgb); +} +float cascadeShadow(vec3 n, vec3 fwd) { + if (F_sunColor.w < 0.5) return 1.0; + float depth = dot(v_world - F_camPos.xyz, fwd); + if (depth > F_splits.w) return 1.0; + int c = depth < F_splits.x ? 0 : depth < F_splits.y ? 1 : depth < F_splits.z ? 2 : 3; + vec4 p = F_cascades[c] * vec4(v_world + n * F_cascadeTexel[c] * 1.5, 1.0); + vec2 uv = p.xy * 0.5 + 0.5; + if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0))) || p.z > 1.0) return 1.0; + vec2 base = vec2(c & 1, c >> 1) * 0.5; + vec2 at = base + uv * 0.5; + float t = F_shadow.x; +#if TIER >= 2 + float lit = 0.0; + for (int y = 0; y < 2; ++y) + for (int x = 0; x < 2; ++x) lit += texture(u_sunShadow, vec3(clamp(at + (vec2(x, y) - 0.5) * t, base + t, base + 0.5 - t), p.z - 0.0002)); + return lit * 0.25; +#else + return texture(u_sunShadow, vec3(at, p.z - 0.0003)); +#endif +} +vec3 sky(vec3 dir, float level) { + float c = cos(F_sky.w), s = sin(F_sky.w); + dir = vec3(c * dir.x + s * dir.y, -s * dir.x + c * dir.y, dir.z); + vec4 m = textureLod(u_sky, vec2(atan(dir.y, dir.x) * 0.15915494 + 0.5, acos(clamp(dir.z, -1.0, 1.0)) * 0.31830989), level); + return m.rgb * m.a * 16.0 * F_sky.y; +} +#if TIER >= 2 +mat3 cotangentFrame(vec3 n, vec3 p, vec2 uv) { + vec3 dp1 = dFdx(p), dp2 = dFdy(p); + vec2 duv1 = dFdx(uv), duv2 = dFdy(uv); + vec3 dp2perp = cross(dp2, n), dp1perp = cross(n, dp1); + vec3 t = dp2perp * duv1.x + dp1perp * duv2.x, b = dp2perp * duv1.y + dp1perp * duv2.y; + float invmax = inversesqrt(max(max(dot(t, t), dot(b, b)), 1e-20)); + return mat3(t * invmax, b * invmax, n); +} +vec3 g_f0 = vec3(0.04); // reflectance at normal incidence: 0.04, or a metal's albedo +vec3 specGGX(vec3 n, vec3 l, vec3 v, float rough) { + vec3 h = normalize(l + v); + float a = max(rough * rough, 0.002), a2 = a * a; + float ndl = max(dot(n, l), 1e-4), ndv = max(dot(n, v), 1e-4), ndh = max(dot(n, h), 0.0), vdh = max(dot(v, h), 0.0); + float q = ndh * ndh * (a2 - 1.0) + 1.0; + float vis = 0.5 / (ndl * sqrt(ndv * ndv * (1.0 - a2) + a2) + ndv * sqrt(ndl * ndl * (1.0 - a2) + a2)); + return min(a2 / (3.14159265 * q * q) * vis * (g_f0 + (1.0 - g_f0) * pow(1.0 - vdh, 5.0)) * 3.14159265, vec3(64.0)); +} +vec2 envBRDF(float rough, float ndv) { + vec4 r = rough * vec4(-1.0, -0.0275, -0.572, 0.022) + vec4(1.0, 0.0425, 1.04, -0.04); + float a004 = min(r.x * r.x, exp2(-9.28 * ndv)) * r.x + r.y; + return vec2(-1.04, 1.04) * a004 + r.zw; +} +#endif + +void main() { + vec4 albedo = texture(u_albedo, v_uv); + if (albedo.a < 0.5) discard; + if (u_params.z > 0.5 && dot(v_world, u_plane.xyz) > u_plane.w) discard; // liquid cut + vec3 base = albedo.rgb * u_tint.rgb; + vec3 n = normalize(v_normal), geo = n; + vec3 view = normalize(F_camPos.xyz - v_world); + vec3 fwd = F_lightCount.yzw; // the camera's forward (packed here) + float gloss = 0.15; +#if TIER >= 2 + mat3 tbn = cotangentFrame(n, v_world, v_uv); + if (u_flags.y > 0.5) { + vec4 m = texture(u_material, v_uv); + vec2 xy = m.xy * 2.0 - 1.0; // Z rebuilt; B is metalness + n = normalize(tbn * vec3(xy, sqrt(max(1.0 - dot(xy, xy), 0.0)))); + gloss = m.w; + g_f0 = mix(vec3(0.04), base, m.z); + base *= 1.0 - m.z; + } +#endif + vec3 sunDir = F_sunDir.xyz; + float facing = dot(geo, sunDir) > 0.0 ? max(dot(n, sunDir), 0.0) : 0.0; + vec3 sun = F_sunColor.rgb * F_sunColor.w * facing; + float vis = facing > 0.0 ? cascadeShadow(geo, fwd) : 0.0; + bool realtime = F_shadow.z > 0.5; + vec3 light, env = vec3(0.0); + float skyVis = 0.0; + if (u_flags.x > 0.5) { + vec4 rgbm = texture(u_lightmap, v_lmuv); + env = rgbm.rgb * rgbm.a * 8.0; + light = realtime ? env + sun * vis : env * (1.0 - F_shadow.y * (1.0 - vis) * facing * F_sunColor.w); + if (u_flags.z > 0.5) skyVis = texture(u_skyVis, v_lmuv).r; + } else if (u_params.y > 0.5) { + env = ambientCube(n); + light = env + sun * (realtime ? vis : u_params.x * vis); + skyVis = 0.5 * u_params.x; + } else { + light = vec3(0.3 + 0.9 * max(dot(n, normalize(vec3(0.4, 0.3, 0.85))), 0.0)); + } + vec3 color = base * light; +#if TIER >= 2 + float rough = clamp(1.0 - gloss, 0.05, 1.0); + color += F_sunColor.rgb * F_sunColor.w * facing * vis * specGGX(n, sunDir, view, rough); + for (int i = 0; i < int(F_lightCount.x); ++i) { + vec4 pr = F_lights[i * 4], lc = F_lights[i * 4 + 1], sp = F_lights[i * 4 + 2], ex = F_lights[i * 4 + 3]; + vec3 toLight = pr.xyz - v_world; + float dist = length(toLight); + if (dist >= pr.w) continue; + vec3 L = toLight / max(dist, 0.001); + float ndl = dot(n, L); + if (ndl <= 0.0) continue; + float a = 1.0 - dist / pr.w, atten = a * a; + if (sp.w > -1.5) atten *= smoothstep(sp.w, ex.x, dot(-L, sp.xyz)); + color += lc.rgb * atten * ndl * (base + specGGX(n, L, view, rough)); + } + if (F_sky.x > 0.5) { + vec3 r = reflect(-view, n); + vec2 ab = envBRDF(rough, clamp(dot(n, view), 1e-4, 1.0)); + color += mix(env, sky(r, rough * F_sky.z), skyVis * skyVis) * (g_f0 * ab.x + ab.y); + } +#endif + float alpha = u_tint.a; + if (alpha < 0.5) { // glass: mostly what's behind, a little reflection + float fresnel = pow(1.0 - clamp(abs(dot(n, view)), 0.0, 1.0), 4.0); + color = color * 0.12 + (F_sky.x > 0.5 ? sky(reflect(-view, n), 1.0) : vec3(0.45, 0.6, 0.8)) * fresnel * 0.8; + alpha = clamp(alpha * 0.6 + fresnel * 0.5, 0.0, 1.0); + } + if (F_sky.x > 0.5 && F_fog.x > 0.0) { // height fog coloured by the sky + vec3 ray = v_world - F_camPos.xyz; + float dist = length(ray); + vec3 rd = ray / max(dist, 1e-3); + float k = F_fog.y, kz = k * rd.z; + float amount = F_fog.x * exp(-k * (F_camPos.z - F_fog.w)) * (abs(kz) > 1e-6 ? (1.0 - exp(-kz * dist)) / kz : dist); + color = mix(color, sky(normalize(vec3(rd.xy, max(rd.z, 0.05))), F_sky.z) * 0.9, min(1.0 - exp(-amount), F_fog.z)); + } + o_color = vec4(color, alpha); +} +)"; + +const char* kSkyVert = R"( +out vec2 v_ndc; +void main() { + vec2 uv = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); + v_ndc = uv * 2.0 - 1.0; + gl_Position = vec4(v_ndc, 1.0, 1.0); +} +)"; +const char* kSkyFrag = R"( +in vec2 v_ndc; +uniform mat4 u_invViewProj; +uniform sampler2D u_sky; +out vec4 o_color; +void main() { + vec4 far = u_invViewProj * vec4(v_ndc, 1.0, 1.0), near = u_invViewProj * vec4(v_ndc, 0.0, 1.0); + vec3 dir = normalize(far.xyz / far.w - near.xyz / near.w); + float c = cos(F_sky.w), s = sin(F_sky.w); + vec3 d = vec3(c * dir.x + s * dir.y, -s * dir.x + c * dir.y, dir.z); + vec4 m = textureLod(u_sky, vec2(atan(d.y, d.x) * 0.15915494 + 0.5, acos(clamp(d.z, -1.0, 1.0)) * 0.31830989), 0.0); + vec3 col = m.rgb * m.a * 16.0 * F_sky.y; + if (F_sunColor.w > 0.5) { + float k = dot(dir, F_sunDir.xyz); + col += F_sunColor.rgb * (smoothstep(0.99995, 0.99999, k) * 60.0 + pow(max(k, 0.0), 600.0) * 1.5 + pow(max(k, 0.0), 40.0) * 0.08); + } + o_color = vec4(col, 1.0); +} +)"; + +const char* kBoxVert = R"( +uniform mat4 u_viewProj; +uniform vec4 u_center, u_extents; +out vec3 v_normal; +const vec3 kNormals[6] = vec3[](vec3(1,0,0), vec3(-1,0,0), vec3(0,1,0), vec3(0,-1,0), vec3(0,0,1), vec3(0,0,-1)); +const vec2 kQuad[6] = vec2[](vec2(-1,-1), vec2(1,-1), vec2(1,1), vec2(-1,-1), vec2(1,1), vec2(-1,1)); +void main() { + vec3 n = kNormals[gl_VertexID / 6]; + vec3 t = abs(n.z) > 0.5 ? vec3(1,0,0) : vec3(0,0,1); + vec2 q = kQuad[gl_VertexID % 6]; + vec3 w = u_center.xyz + (n + t * q.x + cross(n, t) * q.y) * u_extents.xyz; + v_normal = n; + gl_Position = u_viewProj * vec4(w, 1.0); + gl_Position.z = gl_Position.z * 2.0 - gl_Position.w; +} +)"; +const char* kBoxFrag = R"( +in vec3 v_normal; +uniform vec4 u_color; +out vec4 o_color; +void main() { o_color = vec4(u_color.rgb * (0.5 + 0.5 * max(dot(v_normal, normalize(vec3(0.4, 0.3, 0.85))), 0.0)), u_color.a); } +)"; + +const char* kTonemapVert = R"( +out vec2 v_uv; +void main() { + v_uv = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); + gl_Position = vec4(v_uv * 2.0 - 1.0, 0.0, 1.0); + v_uv.y = 1.0 - v_uv.y; // the scene's row 0 is the view's top (as in the main renderer) +} +)"; +const char* kTonemapFrag = R"( +in vec2 v_uv; +uniform sampler2D u_scene; +uniform vec4 u_exposure; +out vec4 o_color; +void main() { + vec3 x = texture(u_scene, v_uv).rgb * u_exposure.x; + x = clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); + o_color = vec4(mix(x * 12.92, 1.055 * pow(x, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, x)), 1.0); +} +)"; + +// What the Frame block holds (std140: matches the GLSL above). +struct FrameBlock { + glm::mat4 cascades[4]; + glm::vec4 splits, cascadeTexel, sunDir, sunColor, shadow, camPos, sky, fog, lightCount; + glm::vec4 lights[64]; +}; +constexpr int kMaxLegacyLights = 16; + +struct Program { + GLuint id = 0; + GLint viewProj = -1, model = -1, skinned = -1, firstJoint = -1, tint = -1, params = -1, plane = -1, flags = -1, cube = -1, + invViewProj = -1, center = -1, extents = -1, color = -1, exposure = -1; +}; + +class LegacyGLBackend final : public RenderBackend { +public: + explicit LegacyGLBackend(int major, int minor) : major_(major), minor_(minor), tier_(major >= 4 ? 2 : 1) { + name_ = "OpenGL " + std::to_string(major) + "." + std::to_string(minor) + " (fallback)"; + } + const char* name() const override { return name_.c_str(); } + GpuInfo gpuInfo() const override { return gpu_; } + GpuInfo gpu_; + + bool init(const Window& window, bool) override { + window_ = window.handle(); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, major_); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, minor_); + // 3.1 has no profiles; 4.0 asks for core. + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, major_ >= 4 ? SDL_GL_CONTEXT_PROFILE_CORE : 0); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, major_ >= 4 ? 0 : SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG); + SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); + SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 0); + context_ = SDL_GL_CreateContext(window_); + if (!context_) { + log::warn("OpenGL {}.{} context: {}", major_, minor_, SDL_GetError()); + return false; + } + SDL_GL_MakeCurrent(window_, context_); + if (!glx::load()) return false; + log::info("GPU: {} ({})", reinterpret_cast(glx::GetString(GL_RENDERER)), reinterpret_cast(glx::GetString(GL_VERSION))); + gpu_.name = reinterpret_cast(glx::GetString(GL_RENDERER)), gpu_.driver = reinterpret_cast(glx::GetString(GL_VERSION)); + glx::PixelStorei(GL_UNPACK_ALIGNMENT, 1); + glx::PixelStorei(GL_PACK_ALIGNMENT, 1); + + const std::string header = "#version 140\n#define TIER " + std::to_string(tier_) + "\n" + kFrameBlock; + mesh_ = link(header + kMeshVert, header + kMeshFrag); + depth_ = link(header + kMeshVert, header + kDepthFrag); + sky_ = link(header + kSkyVert, header + kSkyFrag); + box_ = link(header + kBoxVert, header + kBoxFrag); + tonemap_ = link(header + kTonemapVert, header + kTonemapFrag); + if (!mesh_.id || !depth_.id || !sky_.id || !box_.id || !tonemap_.id) return false; + + glx::GenBuffers(1, &frameUbo_); + glx::BindBuffer(GL_UNIFORM_BUFFER, frameUbo_); + glx::BufferData(GL_UNIFORM_BUFFER, sizeof(FrameBlock), nullptr, GL_DYNAMIC_DRAW); + glx::GenBuffers(1, &jointBuffer_); + glx::BindBuffer(GL_TEXTURE_BUFFER, jointBuffer_); + glx::BufferData(GL_TEXTURE_BUFFER, kMaxJointsPerFrame * sizeof(glm::mat4), nullptr, GL_DYNAMIC_DRAW); + glx::GenTextures(1, &jointTexture_); + glx::BindTexture(GL_TEXTURE_BUFFER, jointTexture_); + glx::TexBuffer(GL_TEXTURE_BUFFER, GL_RGBA32F, jointBuffer_); + glx::GenVertexArrays(1, &emptyVao_); + + // The sun's shadow map: 2×2 cascades, smaller on the 3.1 tier. + shadowSize_ = tier_ >= 2 ? kShadowSize : kShadowSize / 2; + glx::GenTextures(1, &shadowMap_); + glx::BindTexture(GL_TEXTURE_2D, shadowMap_); + glx::TexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT32F, GLsizei(shadowSize_), GLsizei(shadowSize_), 0, GL_DEPTH_COMPONENT, GL_FLOAT, nullptr); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL); + glx::GenFramebuffers(1, &shadowFbo_); + glx::BindFramebuffer(GL_FRAMEBUFFER, shadowFbo_); + glx::FramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, shadowMap_, 0); + glx::DrawBuffer(GL_NONE); + glx::ReadBuffer(GL_NONE); + glx::BindFramebuffer(GL_FRAMEBUFFER, 0); + + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGui::GetIO().IniFilename = nullptr; + ImGui::StyleColorsDark(); + imgui_ = ImGui_ImplSDL3_InitForOpenGL(window_, context_) && ImGui_ImplOpenGL3_Init("#version 140"); + log::info("Fallback renderer: tier {} ({})", tier_, tier_ >= 2 ? "shadows, normal maps, highlights, sky reflections, 16 lights" : "lightmaps, sun shadows, sky"); + return imgui_; + } + + void shutdown() override { + if (!context_) return; + if (imgui_) { + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplSDL3_Shutdown(); + ImGui::DestroyContext(); + imgui_ = false; + } + if (glx::DeleteTextures) { + for (GLuint t : textures_) + if (t) glx::DeleteTextures(1, &t); + for (Model& m : models_) releaseModel(m); + releaseTargets(); + for (GLuint* t : {&shadowMap_, &jointTexture_}) glx::DeleteTextures(1, t); + glx::DeleteFramebuffers(1, &shadowFbo_); + for (GLuint* b : {&frameUbo_, &jointBuffer_}) glx::DeleteBuffers(1, b); + glx::DeleteVertexArrays(1, &emptyVao_); + for (Program* p : {&mesh_, &depth_, &sky_, &box_, &tonemap_}) glx::DeleteProgram(p->id); + } + SDL_GL_DestroyContext(context_); + context_ = nullptr; + } + + void beginUi() override { + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplSDL3_NewFrame(); + ImGui::NewFrame(); + } + void setVsync(bool on) override { SDL_GL_SetSwapInterval(on ? 1 : 0); } + void onResize() override {} + + // No block compression here (old GPUs): the renderer decodes BC7 to RGBA8 first. + void createTexture(TextureId slot, uint32_t w, uint32_t h, bool srgb, const std::vector>& mips, + TexFormat = TexFormat::RGBA8) override { + if (slot >= textures_.size()) textures_.resize(slot + 1, 0); + GLuint& t = textures_[slot]; + if (!t) glx::GenTextures(1, &t); + glx::BindTexture(GL_TEXTURE_2D, t); + for (size_t l = 0; l < mips.size(); ++l) + glx::TexImage2D(GL_TEXTURE_2D, GLint(l), srgb ? GL_SRGB8_ALPHA8 : GL_RGBA8, GLsizei(std::max(1u, w >> l)), GLsizei(std::max(1u, h >> l)), 0, GL_RGBA, + GL_UNSIGNED_BYTE, mips[l].data()); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, GLint(mips.size()) - 1); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, mips.size() > 1 ? GL_LINEAR_MIPMAP_LINEAR : GL_LINEAR); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); + } + + void uploadModel(uint32_t index, const KmdlModel& kmdl) override { + if (index >= models_.size()) models_.resize(index + 1); + releaseModel(models_[index]); + Model& m = models_[index]; + glx::GenVertexArrays(1, &m.vao); + glx::BindVertexArray(m.vao); + glx::GenBuffers(1, &m.vertices); + glx::BindBuffer(GL_ARRAY_BUFFER, m.vertices); + glx::BufferData(GL_ARRAY_BUFFER, GLsizeiptr(kmdl.vertices.size() * sizeof(KmdlVertex)), kmdl.vertices.data(), GL_STATIC_DRAW); + const GLsizei stride = sizeof(KmdlVertex); + const struct { GLuint loc; GLint n; size_t off; } attrs[] = {{0, 3, offsetof(KmdlVertex, position)}, {1, 3, offsetof(KmdlVertex, normal)}, + {2, 2, offsetof(KmdlVertex, uv)}, {3, 2, offsetof(KmdlVertex, lightmapUv)}}; + for (const auto& a : attrs) { + glx::EnableVertexAttribArray(a.loc); + glx::VertexAttribPointer(a.loc, a.n, GL_FLOAT, GL_FALSE, stride, reinterpret_cast(a.off)); + } + if (!kmdl.skin.empty()) { + glx::GenBuffers(1, &m.skin); + glx::BindBuffer(GL_ARRAY_BUFFER, m.skin); + glx::BufferData(GL_ARRAY_BUFFER, GLsizeiptr(kmdl.skin.size() * sizeof(KmdlSkin)), kmdl.skin.data(), GL_STATIC_DRAW); + glx::EnableVertexAttribArray(4); + glx::VertexAttribIPointer(4, 4, GL_UNSIGNED_BYTE, sizeof(KmdlSkin), reinterpret_cast(offsetof(KmdlSkin, joints))); + glx::EnableVertexAttribArray(5); + glx::VertexAttribPointer(5, 4, GL_UNSIGNED_BYTE, GL_TRUE, sizeof(KmdlSkin), reinterpret_cast(offsetof(KmdlSkin, weights))); + } + glx::GenBuffers(1, &m.indices); + glx::BindBuffer(GL_ELEMENT_ARRAY_BUFFER, m.indices); + glx::BufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(kmdl.indices.size() * 4), kmdl.indices.data(), GL_STATIC_DRAW); + glx::BindVertexArray(0); + m.skinned = !kmdl.skin.empty(); + } + + uint64_t uiTexture(TextureId slot) override { return slot < textures_.size() ? uint64_t(textures_[slot]) : 0; } + + bool beginFrame(uint32_t& width, uint32_t& height) override { + int w = 0, h = 0; + if (SDL_GetWindowFlags(window_) & SDL_WINDOW_MINIMIZED) return false; + SDL_GetWindowSizeInPixels(window_, &w, &h); + if (w <= 0 || h <= 0) return false; + ensureTargets(uint32_t(w), uint32_t(h)); + width = uint32_t(w); + height = uint32_t(h); + return true; + } + + bool takeHzb(HzbReadback&) override { return false; } // no occlusion culling here + + void render(FramePacket& p) override; + +private: + struct Model { + GLuint vao = 0, vertices = 0, indices = 0, skin = 0; + bool skinned = false; + }; + + Program link(const std::string& vs, const std::string& fs) { + auto compile = [](GLenum type, const std::string& src) -> GLuint { + const GLuint s = glx::CreateShader(type); + const char* text = src.c_str(); + glx::ShaderSource(s, 1, &text, nullptr); + glx::CompileShader(s); + GLint ok = 0; + glx::GetShaderiv(s, GL_COMPILE_STATUS, &ok); + if (!ok) { + char logText[4096]; + glx::GetShaderInfoLog(s, sizeof logText, nullptr, logText); + log::error("OpenGL (legacy): shader failed to compile:\n{}", logText); + glx::DeleteShader(s); + return 0; + } + return s; + }; + Program p; + const GLuint v = compile(GL_VERTEX_SHADER, vs), f = compile(GL_FRAGMENT_SHADER, fs); + if (!v || !f) return p; + p.id = glx::CreateProgram(); + glx::AttachShader(p.id, v); + glx::AttachShader(p.id, f); + const char* attrs[] = {"a_pos", "a_normal", "a_uv", "a_lmuv", "a_joints", "a_weights"}; + for (GLuint i = 0; i < 6; ++i) glx::BindAttribLocation(p.id, i, attrs[i]); + glx::BindFragDataLocation(p.id, 0, "o_color"); + glx::LinkProgram(p.id); + glx::DeleteShader(v); + glx::DeleteShader(f); + GLint ok = 0; + glx::GetProgramiv(p.id, GL_LINK_STATUS, &ok); + if (!ok) { + char logText[4096]; + glx::GetProgramInfoLog(p.id, sizeof logText, nullptr, logText); + log::error("OpenGL (legacy): program failed to link:\n{}", logText); + glx::DeleteProgram(p.id); + p.id = 0; + return p; + } + if (const GLuint block = glx::GetUniformBlockIndex(p.id, "Frame"); block != GL_INVALID_INDEX) glx::UniformBlockBinding(p.id, block, 0); + auto u = [&](const char* n) { return glx::GetUniformLocation(p.id, n); }; + p.viewProj = u("u_viewProj"), p.model = u("u_model"), p.skinned = u("u_skinned"), p.firstJoint = u("u_firstJoint"); + p.tint = u("u_tint"), p.params = u("u_params"), p.plane = u("u_plane"), p.flags = u("u_flags"), p.cube = u("u_cube"); + p.invViewProj = u("u_invViewProj"), p.center = u("u_center"), p.extents = u("u_extents"), p.color = u("u_color"), p.exposure = u("u_exposure"); + // Fixed texture units. + glx::UseProgram(p.id); + const struct { const char* name; GLint unit; } units[] = {{"u_albedo", 0}, {"u_lightmap", 1}, {"u_material", 2}, {"u_skyVis", 3}, + {"u_sunShadow", 4}, {"u_sky", 5}, {"u_joints", 6}, {"u_scene", 0}}; + for (const auto& t : units) + if (const GLint loc = u(t.name); loc >= 0) glx::Uniform1i(loc, t.unit); + glx::UseProgram(0); + return p; + } + + void releaseModel(Model& m) { + if (m.vao) glx::DeleteVertexArrays(1, &m.vao); + for (GLuint* b : {&m.vertices, &m.indices, &m.skin}) + if (*b) glx::DeleteBuffers(1, b); + m = {}; + } + void releaseTargets() { + if (sceneTex_) glx::DeleteTextures(1, &sceneTex_); + if (sceneDepth_) glx::DeleteTextures(1, &sceneDepth_); + if (sceneFbo_) glx::DeleteFramebuffers(1, &sceneFbo_); + sceneTex_ = sceneDepth_ = sceneFbo_ = 0; + } + void ensureTargets(uint32_t w, uint32_t h) { + if (sceneFbo_ && w == targetW_ && h == targetH_) return; + releaseTargets(); + glx::GenTextures(1, &sceneTex_); + glx::BindTexture(GL_TEXTURE_2D, sceneTex_); + glx::TexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, GLsizei(w), GLsizei(h), 0, GL_RGBA, GL_FLOAT, nullptr); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glx::GenTextures(1, &sceneDepth_); + glx::BindTexture(GL_TEXTURE_2D, sceneDepth_); + glx::TexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, GLsizei(w), GLsizei(h), 0, GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, nullptr); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glx::GenFramebuffers(1, &sceneFbo_); + glx::BindFramebuffer(GL_FRAMEBUFFER, sceneFbo_); + glx::FramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, sceneTex_, 0); + glx::FramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, sceneDepth_, 0); + if (glx::CheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) log::error("OpenGL (legacy): scene framebuffer incomplete"); + glx::BindFramebuffer(GL_FRAMEBUFFER, 0); + targetW_ = w, targetH_ = h; + } + void bindTexture(GLint unit, TextureId slot) { + glx::ActiveTexture(GL_TEXTURE0 + GLenum(unit)); + glx::BindTexture(GL_TEXTURE_2D, slot < textures_.size() && textures_[slot] ? textures_[slot] : textures_[kWhiteTexture]); + } + void drawMeshes(const FramePacket& p, const std::vector& draws, const glm::mat4& viewProj, const Program& prog, bool shadow); + + std::string name_; + int major_, minor_, tier_; + SDL_Window* window_ = nullptr; + SDL_GLContext context_ = nullptr; + bool imgui_ = false; + Program mesh_, depth_, sky_, box_, tonemap_; + std::vector textures_; + std::vector models_; + GLuint frameUbo_ = 0, jointBuffer_ = 0, jointTexture_ = 0, emptyVao_ = 0; + GLuint shadowMap_ = 0, shadowFbo_ = 0; + uint32_t shadowSize_ = kShadowSize; + GLuint sceneTex_ = 0, sceneDepth_ = 0, sceneFbo_ = 0; + uint32_t targetW_ = 0, targetH_ = 0; +}; + +void LegacyGLBackend::drawMeshes(const FramePacket& p, const std::vector& draws, const glm::mat4& viewProj, const Program& prog, bool shadow) { + glx::UseProgram(prog.id); + glx::UniformMatrix4fv(prog.viewProj, 1, GL_FALSE, glm::value_ptr(viewProj)); + uint32_t boundModel = UINT32_MAX; + for (const MeshDraw& d : draws) { + if (d.model >= models_.size() || !models_[d.model].vao) continue; + const Model& m = models_[d.model]; + if (d.model != boundModel) glx::BindVertexArray(m.vao), boundModel = d.model; + const gpu::DrawData& dd = p.draws[d.draw]; + const bool skinned = d.firstJoint != UINT32_MAX && m.skinned; + if (!skinned) glx::VertexAttribI4ui(4, 0, 0, 0, 0), glx::VertexAttrib4f(5, 1, 0, 0, 0); + glx::UniformMatrix4fv(prog.model, 1, GL_FALSE, glm::value_ptr(dd.model)); + glx::Uniform1i(prog.skinned, skinned ? 1 : 0); + glx::Uniform1i(prog.firstJoint, skinned ? GLint(d.firstJoint) : 0); + bindTexture(0, d.textures & 0xFFFFu); + if (!shadow) { + const TextureId lightmap = d.textures >> 16, material = d.material & 0xFFFFu, skyVis = d.material >> 16; + if (lightmap) bindTexture(1, lightmap); + if (material && tier_ >= 2) bindTexture(2, material); + if (skyVis) bindTexture(3, skyVis); + const glm::vec4 tint = glm::unpackUnorm4x8(d.tint); + glx::Uniform4fv(prog.tint, 1, glm::value_ptr(tint)); + glx::Uniform4fv(prog.params, 1, glm::value_ptr(dd.params)); + glx::Uniform4fv(prog.plane, 1, glm::value_ptr(dd.plane)); + const glm::vec4 flags(lightmap ? 1.0f : 0.0f, material ? 1.0f : 0.0f, skyVis ? 1.0f : 0.0f, 0.0f); + glx::Uniform4fv(prog.flags, 1, glm::value_ptr(flags)); + glx::Uniform4fv(prog.cube, 6, glm::value_ptr(dd.cube[0])); + } + glx::DrawElements(GL_TRIANGLES, GLsizei(d.indexCount), GL_UNSIGNED_INT, reinterpret_cast(uintptr_t(d.firstIndex) * 4)); + } + glx::BindVertexArray(0); +} + +void LegacyGLBackend::render(FramePacket& p) { + // ── Per-frame data: the Frame block (the 16 nearest lights on tier 2), joints ── + FrameBlock fb{}; + std::memcpy(fb.cascades, p.frame.cascades, sizeof fb.cascades); + fb.splits = p.frame.splits, fb.cascadeTexel = p.frame.cascadeTexel, fb.sunDir = p.frame.sunDir, fb.sunColor = p.frame.sunColor; + fb.shadow = glm::vec4(1.0f / float(shadowSize_), p.frame.shadow.y, p.frame.shadow.z, 0); + fb.camPos = p.frame.camPos, fb.sky = p.frame.sky, fb.fog = p.frame.fog; + std::vector closest; + if (tier_ >= 2) { + for (const gpu::Light& l : p.lights) closest.push_back(&l); + const glm::vec3 cam(p.frame.camPos); + std::sort(closest.begin(), closest.end(), [&](auto* a, auto* b) { + return glm::length(glm::vec3(a->posRadius) - cam) - a->posRadius.w < glm::length(glm::vec3(b->posRadius) - cam) - b->posRadius.w; + }); + closest.resize(std::min(closest.size(), kMaxLegacyLights)); + } + fb.lightCount = glm::vec4(float(closest.size()), glm::vec3(p.frame.camFwd)); + for (size_t i = 0; i < closest.size(); ++i) + fb.lights[i * 4] = closest[i]->posRadius, fb.lights[i * 4 + 1] = closest[i]->color, fb.lights[i * 4 + 2] = closest[i]->spot, fb.lights[i * 4 + 3] = closest[i]->extra; + glx::BindBuffer(GL_UNIFORM_BUFFER, frameUbo_); + glx::BufferSubData(GL_UNIFORM_BUFFER, 0, sizeof fb, &fb); + glx::BindBufferBase(GL_UNIFORM_BUFFER, 0, frameUbo_); + if (!p.joints.empty()) { + glx::BindBuffer(GL_TEXTURE_BUFFER, jointBuffer_); + glx::BufferSubData(GL_TEXTURE_BUFFER, 0, GLsizeiptr(std::min(p.joints.size(), size_t(kMaxJointsPerFrame)) * sizeof(glm::mat4)), p.joints.data()); + } + glx::ActiveTexture(GL_TEXTURE6); + glx::BindTexture(GL_TEXTURE_BUFFER, jointTexture_); + glx::Disable(GL_CULL_FACE); + glx::Disable(GL_BLEND); + glx::Enable(GL_DEPTH_TEST); + glx::DepthFunc(GL_LESS); + glx::DepthMask(GL_TRUE); + + // ── Sun shadows (light shadows aren't drawn on this renderer) ── + glx::BindFramebuffer(GL_FRAMEBUFFER, shadowFbo_); + glx::Viewport(0, 0, GLsizei(shadowSize_), GLsizei(shadowSize_)); + glx::ClearDepth(1.0); + glx::Clear(GL_DEPTH_BUFFER_BIT); + glx::Enable(GL_POLYGON_OFFSET_FILL); + glx::PolygonOffset(2.5f, 2.0f); + const float scale = float(shadowSize_) / float(p.shadowSize); + for (const ShadowPass& pass : p.sunPasses) { + glx::Viewport(GLint(pass.rect.x * scale), GLint(pass.rect.y * scale), GLsizei(pass.rect.z * scale), GLsizei(pass.rect.w * scale)); + drawMeshes(p, *pass.draws, pass.viewProj, depth_, true); + } + glx::Disable(GL_POLYGON_OFFSET_FILL); + + // ── Scene (HDR) ── + glx::BindFramebuffer(GL_FRAMEBUFFER, sceneFbo_); + glx::Viewport(0, 0, GLsizei(p.width), GLsizei(p.height)); + glx::ClearColor(p.clearColor.r, p.clearColor.g, p.clearColor.b, 1.0f); + glx::Clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + glx::ActiveTexture(GL_TEXTURE4); + glx::BindTexture(GL_TEXTURE_2D, shadowMap_); + const TextureId skyTex = TextureId(p.frame.sky.x); + if (skyTex) bindTexture(5, skyTex); + if (!p.boxes.empty()) { + glx::UseProgram(box_.id); + glx::UniformMatrix4fv(box_.viewProj, 1, GL_FALSE, glm::value_ptr(p.viewProj)); + glx::BindVertexArray(emptyVao_); + for (const gpu::Box& b : p.boxes) { + glx::Uniform4fv(box_.center, 1, glm::value_ptr(b.center)); + glx::Uniform4fv(box_.extents, 1, glm::value_ptr(b.extents)); + glx::Uniform4fv(box_.color, 1, glm::value_ptr(b.color)); + glx::DrawArrays(GL_TRIANGLES, 0, 36); + } + } + drawMeshes(p, p.opaque, p.viewProj, mesh_, false); + if (skyTex) { + glx::UseProgram(sky_.id); + glx::UniformMatrix4fv(sky_.invViewProj, 1, GL_FALSE, glm::value_ptr(p.invViewProj)); + glx::DepthFunc(GL_LEQUAL); + glx::DepthMask(GL_FALSE); + glx::BindVertexArray(emptyVao_); + glx::DrawArrays(GL_TRIANGLES, 0, 3); + glx::DepthMask(GL_TRUE); + glx::DepthFunc(GL_LESS); + } + if (!p.translucent.empty()) { + glx::Enable(GL_BLEND); + glx::BlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); + glx::DepthMask(GL_FALSE); + drawMeshes(p, p.translucent, p.viewProj, mesh_, false); + glx::DepthMask(GL_TRUE); + glx::Disable(GL_BLEND); + } + + // ── Tonemap to the window, then UI ── + glx::BindFramebuffer(GL_FRAMEBUFFER, 0); + glx::Viewport(0, 0, GLsizei(p.width), GLsizei(p.height)); + glx::Disable(GL_DEPTH_TEST); + glx::UseProgram(tonemap_.id); + const glm::vec4 exposure(p.exposure, 0, 0, 0); + glx::Uniform4fv(tonemap_.exposure, 1, glm::value_ptr(exposure)); + glx::ActiveTexture(GL_TEXTURE0); + glx::BindTexture(GL_TEXTURE_2D, sceneTex_); + glx::BindVertexArray(emptyVao_); + glx::DrawArrays(GL_TRIANGLES, 0, 3); + glx::BindVertexArray(0); + glx::UseProgram(0); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + + if (!p.screenshot.empty()) { + std::vector rows(size_t(p.width) * p.height * 4), px(rows.size()); + glx::ReadBuffer(GL_BACK); + glx::ReadPixels(0, 0, GLsizei(p.width), GLsizei(p.height), GL_RGBA, GL_UNSIGNED_BYTE, rows.data()); + const size_t stride = size_t(p.width) * 4; // the window's row 0 is its bottom + for (uint32_t y = 0; y < p.height; ++y) std::memcpy(&px[y * stride], &rows[(p.height - 1 - y) * stride], stride); + saveScreenshot(p.screenshot, std::move(px), p.width, p.height); + } + SDL_GL_SwapWindow(window_); +} +} // namespace + +std::unique_ptr createLegacyOpenGLBackend(int major, int minor) { return std::make_unique(major, minor); } + +} // namespace keel diff --git a/engine/rhi/gl/gl_loader.cpp b/engine/rhi/gl/gl_loader.cpp new file mode 100644 index 0000000..b843793 --- /dev/null +++ b/engine/rhi/gl/gl_loader.cpp @@ -0,0 +1,21 @@ +#include "rhi/gl/gl_loader.h" +#include "core/log.h" + +#include + +#include + +namespace keel::gl { + +bool load() { + std::string missing; +#define KEEL_GL_LOAD(ret, name, params) \ + name = reinterpret_cast(SDL_GL_GetProcAddress("gl" #name)); \ + if (!name) missing += " gl" #name; + KEEL_GL_FUNCTIONS(KEEL_GL_LOAD) +#undef KEEL_GL_LOAD + if (!missing.empty()) log::warn("OpenGL: missing functions:{}", missing); + return missing.empty(); +} + +} // namespace keel::gl diff --git a/engine/rhi/gl/gl_loader.h b/engine/rhi/gl/gl_loader.h new file mode 100644 index 0000000..0319edf --- /dev/null +++ b/engine/rhi/gl/gl_loader.h @@ -0,0 +1,87 @@ +#pragma once +// OpenGL 4.6 entry points, loaded through SDL at runtime (no GL library linked, no extra +// dependency): gl::Enable(...), gl::DrawElements(...). Types and constants come from SDL's +// copy of the Khronos headers. Add a function to KEEL_GL_FUNCTIONS to use it. +#include + +#define KEEL_GL_FUNCTIONS(X) \ + X(const GLubyte*, GetString, (GLenum name)) \ + X(const GLubyte*, GetStringi, (GLenum name, GLuint index)) \ + X(void, GetIntegerv, (GLenum pname, GLint* data)) \ + X(GLenum, GetError, (void)) \ + X(void, Enable, (GLenum cap)) \ + X(void, Disable, (GLenum cap)) \ + X(void, ClipControl, (GLenum origin, GLenum depth)) \ + X(void, FrontFace, (GLenum mode)) \ + X(void, Viewport, (GLint x, GLint y, GLsizei w, GLsizei h)) \ + X(void, Scissor, (GLint x, GLint y, GLsizei w, GLsizei h)) \ + X(void, DepthFunc, (GLenum func)) \ + X(void, DepthMask, (GLboolean flag)) \ + X(void, BlendFuncSeparate, (GLenum sc, GLenum dc, GLenum sa, GLenum da)) \ + X(void, PolygonOffset, (GLfloat factor, GLfloat units)) \ + X(void, PixelStorei, (GLenum pname, GLint param)) \ + X(void, CreateBuffers, (GLsizei n, GLuint* buffers)) \ + X(void, DeleteBuffers, (GLsizei n, const GLuint* buffers)) \ + X(void, NamedBufferStorage, (GLuint buffer, GLsizeiptr size, const void* data, GLbitfield flags)) \ + X(void, NamedBufferSubData, (GLuint buffer, GLintptr offset, GLsizeiptr size, const void* data)) \ + X(void, BindBufferBase, (GLenum target, GLuint index, GLuint buffer)) \ + X(void, CreateTextures, (GLenum target, GLsizei n, GLuint* textures)) \ + X(void, DeleteTextures, (GLsizei n, const GLuint* textures)) \ + X(void, TextureStorage2D, (GLuint texture, GLsizei levels, GLenum format, GLsizei w, GLsizei h)) \ + X(void, TextureSubImage2D, (GLuint texture, GLint level, GLint x, GLint y, GLsizei w, GLsizei h, GLenum format, \ + GLenum type, const void* pixels)) \ + X(void, CompressedTextureSubImage2D, (GLuint texture, GLint level, GLint x, GLint y, GLsizei w, GLsizei h, \ + GLenum format, GLsizei size, const void* data)) \ + X(void, CreateSamplers, (GLsizei n, GLuint* samplers)) \ + X(void, DeleteSamplers, (GLsizei n, const GLuint* samplers)) \ + X(void, SamplerParameteri, (GLuint sampler, GLenum pname, GLint param)) \ + X(void, SamplerParameterf, (GLuint sampler, GLenum pname, GLfloat param)) \ + X(GLuint64, GetTextureSamplerHandleARB, (GLuint texture, GLuint sampler)) \ + X(void, MakeTextureHandleResidentARB, (GLuint64 handle)) \ + X(void, MakeTextureHandleNonResidentARB, (GLuint64 handle)) \ + X(void, CreateFramebuffers, (GLsizei n, GLuint* framebuffers)) \ + X(void, DeleteFramebuffers, (GLsizei n, const GLuint* framebuffers)) \ + X(void, NamedFramebufferTexture, (GLuint fb, GLenum attachment, GLuint texture, GLint level)) \ + X(void, NamedFramebufferDrawBuffer, (GLuint fb, GLenum buf)) \ + X(GLenum, CheckNamedFramebufferStatus, (GLuint fb, GLenum target)) \ + X(void, BindFramebuffer, (GLenum target, GLuint fb)) \ + X(void, NamedFramebufferDrawBuffers, (GLuint fb, GLsizei n, const GLenum* bufs)) \ + X(void, ColorMaski, (GLuint index, GLboolean r, GLboolean g, GLboolean b, GLboolean a)) \ + X(void, ClearNamedFramebufferfv, (GLuint fb, GLenum buffer, GLint drawbuffer, const GLfloat* value)) \ + X(GLuint, CreateShader, (GLenum type)) \ + X(void, ShaderSource, (GLuint shader, GLsizei count, const GLchar* const* string, const GLint* length)) \ + X(void, CompileShader, (GLuint shader)) \ + X(void, GetShaderiv, (GLuint shader, GLenum pname, GLint* params)) \ + X(void, GetShaderInfoLog, (GLuint shader, GLsizei max, GLsizei* length, GLchar* log)) \ + X(void, DeleteShader, (GLuint shader)) \ + X(GLuint, CreateProgram, (void)) \ + X(void, AttachShader, (GLuint program, GLuint shader)) \ + X(void, LinkProgram, (GLuint program)) \ + X(void, GetProgramiv, (GLuint program, GLenum pname, GLint* params)) \ + X(void, GetProgramInfoLog, (GLuint program, GLsizei max, GLsizei* length, GLchar* log)) \ + X(void, DeleteProgram, (GLuint program)) \ + X(void, UseProgram, (GLuint program)) \ + X(void, CreateVertexArrays, (GLsizei n, GLuint* arrays)) \ + X(void, DeleteVertexArrays, (GLsizei n, const GLuint* arrays)) \ + X(void, BindVertexArray, (GLuint array)) \ + X(void, VertexArrayElementBuffer, (GLuint vao, GLuint buffer)) \ + X(void, DrawElements, (GLenum mode, GLsizei count, GLenum type, const void* indices)) \ + X(void, DrawArrays, (GLenum mode, GLint first, GLsizei count)) \ + X(void, ReadPixels, (GLint x, GLint y, GLsizei w, GLsizei h, GLenum format, GLenum type, void* pixels)) \ + X(void, ReadBuffer, (GLenum src)) \ + X(GLsync, FenceSync, (GLenum condition, GLbitfield flags)) \ + X(GLenum, ClientWaitSync, (GLsync sync, GLbitfield flags, GLuint64 timeout)) \ + X(void, DeleteSync, (GLsync sync)) \ + X(void, BindBuffer, (GLenum target, GLuint buffer)) \ + X(void*, MapNamedBufferRange, (GLuint buffer, GLintptr offset, GLsizeiptr length, GLbitfield access)) \ + X(GLboolean, UnmapNamedBuffer, (GLuint buffer)) \ + X(void, DebugMessageCallback, (GLDEBUGPROC callback, const void* user)) + +namespace keel::gl { +#define KEEL_GL_DECLARE(ret, name, params) using name##_t = ret(APIENTRY*) params; inline name##_t name = nullptr; +KEEL_GL_FUNCTIONS(KEEL_GL_DECLARE) +#undef KEEL_GL_DECLARE + +// Loads every entry point from the current context; false (and logs which) if any is missing. +bool load(); +} // namespace keel::gl diff --git a/engine/rhi/vulkan/vk_backend.cpp b/engine/rhi/vulkan/vk_backend.cpp new file mode 100644 index 0000000..18a5280 --- /dev/null +++ b/engine/rhi/vulkan/vk_backend.cpp @@ -0,0 +1,1641 @@ +// The Vulkan 1.3 backend: bindless textures (one descriptor array), buffer device addresses +// for every per-draw and per-frame buffer (passed in push constants), dynamic rendering. +#include "render/backend.h" +#include "render/screenshot.h" +#include "rhi/vulkan/vk_device.h" +#include "core/log.h" +#include "platform/window.h" + +#include +#include +#include + +#include +#include +#include + +namespace keel { +namespace { +#define SPV(name) const uint32_t name[] = +SPV(kBoxVert) +#include "box.vert.spv.inc" +; +SPV(kBoxFrag) +#include "box.frag.spv.inc" +; +SPV(kMeshVert) +#include "mesh.vert.spv.inc" +; +SPV(kMeshFrag) +#include "mesh.frag.spv.inc" +; +SPV(kDepthFrag) +#include "depth.frag.spv.inc" +; +SPV(kCullComp) +#include "cull.comp.spv.inc" +; +SPV(kFroxelComp) +#include "froxel.comp.spv.inc" +; +SPV(kFroxelRtComp) +#include "froxel_rt.comp.spv.inc" +; +SPV(kGiComp) +#include "gi.comp.spv.inc" +; +SPV(kSkinnedVert) +#include "skinned.vert.spv.inc" +; +SPV(kFullscreenVert) +#include "fullscreen.vert.spv.inc" +; +SPV(kTonemapFrag) +#include "tonemap.frag.spv.inc" +; +SPV(kSkyVert) +#include "sky.vert.spv.inc" +; +SPV(kSkyFrag) +#include "sky.frag.spv.inc" +; +SPV(kSmokeSkyFrag) +#include "smoke_sky.frag.spv.inc" +; +SPV(kHzbFrag) +#include "hzb.frag.spv.inc" +; +SPV(kSsaoFrag) +#include "ssao.frag.spv.inc" +; +SPV(kSsaoBlurFrag) +#include "ssao_blur.frag.spv.inc" +; +SPV(kBloomDownFrag) +#include "bloom_down.frag.spv.inc" +; +SPV(kBloomUpFrag) +#include "bloom_up.frag.spv.inc" +; +SPV(kTaaFrag) +#include "taa.frag.spv.inc" +; +SPV(kSsrFrag) +#include "ssr.frag.spv.inc" +; +SPV(kVolumetricFrag) +#include "volumetric.frag.spv.inc" +; +SPV(kSpriteVert) +#include "sprite.vert.spv.inc" +; +SPV(kSpriteFrag) +#include "sprite.frag.spv.inc" +; +SPV(kAdaptFrag) +#include "adapt.frag.spv.inc" +; +#undef SPV + +struct BoxPush { + glm::mat4 viewProj; + gpu::Box box; +}; +struct ModelPush { // matches model.glsl (Vulkan) + glm::mat4 viewProj; + VkDeviceAddress vertices, skin, joints, draw, frame; + uint32_t textures; // albedo | lightmap << 16 + uint32_t tint; + uint32_t material; // relief + gloss map, 0 = none +}; +struct TonemapPush { + uint32_t scene; + float exposure; + uint32_t ao, bloom; + float bloomStrength, saturation, vignette, sharpen; + uint32_t depth, pad[3]; + glm::vec4 shafts, sunColor; +}; +struct SsaoPush { + uint32_t depth, w, h, outW, outH; + float radius, strength, pad; + glm::vec4 proj; +}; +struct SsaoBlurPush { + uint32_t ao, depth, w, h, outW, outH; + float pad0, pad1; + glm::vec4 proj; +}; +struct TaaPush { + glm::mat4 reproject; + uint32_t scene, history, depth, w, h, reset, ssr; + float pad1; +}; +struct AdaptPush { + uint32_t src, prev; + float dt, speed, key, minE, maxE, pad; +}; +struct SpritePush { + glm::mat4 viewProj; + gpu::Sprite sprite; +}; +struct VolumetricPush { + uint64_t frame; + uint32_t depth, w, h, outW, outH; + float pad[5]; // proj sits at 48 (vec4 alignment) + glm::vec4 proj; +}; +struct SsrPush { + uint32_t depth, gbuf, scene, w, h, outW, outH; + float strength; + glm::vec4 proj; +}; +struct BloomPush { + uint32_t src, srcW, srcH, outW, outH; + uint32_t extra; // down: first level; up: radius (float bits) +}; +struct HzbPush { + uint32_t depth, srcW, srcH, dstW, dstH; +}; +static_assert(sizeof(BoxPush) <= 128 && sizeof(ModelPush) <= 128 && sizeof(gpu::SkyPush) <= 128 && sizeof(SsaoPush) <= 128, "push constants must fit the guaranteed 128 bytes"); +constexpr VkShaderStageFlags kAllStages = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT | VK_SHADER_STAGE_COMPUTE_BIT; +constexpr VkFormat kSceneFormat = VK_FORMAT_R16G16B16A16_SFLOAT; +constexpr VkFormat kGbufFormat = VK_FORMAT_R16G16B16A16_SFLOAT; // view normal (oct), roughness, reflection weight +constexpr VkFormat kDepthFormat = VK_FORMAT_D32_SFLOAT; + +// One per-frame buffer holds the lights, shadow views, cluster cells and light indices. +constexpr VkDeviceSize kLightsOffset = 0, kViewsOffset = kMaxLights * sizeof(gpu::Light), + kCellsOffset = kViewsOffset + kMaxShadowViews * sizeof(gpu::ShadowView), + kIndicesOffset = kCellsOffset + kClusterX * kClusterY * kClusterZ * 8, kLightBufferSize = kIndicesOffset + kMaxLightIndices * 4; + +struct PipelineDesc { + const uint32_t* vs; + size_t vsSize; + const uint32_t* fs; + size_t fsSize; + VkFormat color; // UNDEFINED = depth only (shadow maps) + bool depth; + bool depthBias = false; + bool blend = false; // alpha blended, depth test without writes (translucent pass) + bool sky = false; // on the far plane: depth less-or-equal, no writes + bool additive = false; // adds onto what's there (bloom coming up) + bool over = false; // premultiplied, onto what's there: src + dst × src alpha (fog, smoke) + int gbuf = 0; // the scene pass's second attachment (G-buffer): 0 none, 1 there but not written, 2 written + bool glow = false; // light added on by its alpha, depth tested but not written (particles) + bool prepassed = false; // the depth is already there (pre-pass): test less-or-equal, don't write +}; + +VkPipeline makePipeline(VkDevice dev, VkPipelineLayout layout, const PipelineDesc& d) { + auto shader = [&](const uint32_t* code, size_t size) { + VkShaderModuleCreateInfo ci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO}; + ci.codeSize = size; + ci.pCode = code; + VkShaderModule m; + VK_CHECK(vkCreateShaderModule(dev, &ci, nullptr, &m)); + return m; + }; + VkShaderModule vs = shader(d.vs, d.vsSize), fs = d.fs ? shader(d.fs, d.fsSize) : VK_NULL_HANDLE; + VkPipelineShaderStageCreateInfo stages[2] = { + {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, nullptr, 0, VK_SHADER_STAGE_VERTEX_BIT, vs, "main", nullptr}, + {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, nullptr, 0, VK_SHADER_STAGE_FRAGMENT_BIT, fs, "main", nullptr}, + }; + VkPipelineVertexInputStateCreateInfo vertexInput{VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO}; + VkPipelineInputAssemblyStateCreateInfo ia{VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO}; + ia.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + VkPipelineViewportStateCreateInfo vp{VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO}; + vp.viewportCount = 1; + vp.scissorCount = 1; + VkPipelineRasterizationStateCreateInfo rs{VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO}; + rs.polygonMode = VK_POLYGON_MODE_FILL; + rs.cullMode = VK_CULL_MODE_NONE; + rs.lineWidth = 1.0f; + if (d.depthBias) { + rs.depthBiasEnable = VK_TRUE; + rs.depthBiasConstantFactor = 2.0f; + rs.depthBiasSlopeFactor = 2.5f; + } + VkPipelineMultisampleStateCreateInfo ms{VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO}; + ms.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; + VkPipelineDepthStencilStateCreateInfo ds{VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO}; + ds.depthTestEnable = d.depth; + ds.depthWriteEnable = d.depth; + ds.depthCompareOp = d.sky ? VK_COMPARE_OP_LESS_OR_EQUAL : VK_COMPARE_OP_LESS; + if (d.sky) ds.depthWriteEnable = VK_FALSE; + if (d.prepassed) ds.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL, ds.depthWriteEnable = VK_FALSE; + VkPipelineColorBlendAttachmentState blendAtt{}; + blendAtt.colorWriteMask = 0xF; + if (d.additive) { + blendAtt.blendEnable = VK_TRUE; + blendAtt.srcColorBlendFactor = blendAtt.dstColorBlendFactor = VK_BLEND_FACTOR_ONE; + blendAtt.srcAlphaBlendFactor = blendAtt.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + blendAtt.colorBlendOp = blendAtt.alphaBlendOp = VK_BLEND_OP_ADD; + } + if (d.over) { + blendAtt.blendEnable = VK_TRUE; + blendAtt.srcColorBlendFactor = VK_BLEND_FACTOR_ONE; + blendAtt.dstColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA; + blendAtt.srcAlphaBlendFactor = VK_BLEND_FACTOR_ZERO; + blendAtt.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + blendAtt.colorBlendOp = blendAtt.alphaBlendOp = VK_BLEND_OP_ADD; + } + if (d.glow) { + blendAtt.blendEnable = VK_TRUE; + blendAtt.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA; + blendAtt.dstColorBlendFactor = VK_BLEND_FACTOR_ONE; + blendAtt.srcAlphaBlendFactor = blendAtt.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + blendAtt.colorBlendOp = blendAtt.alphaBlendOp = VK_BLEND_OP_ADD; + ds.depthWriteEnable = VK_FALSE; + } + if (d.blend) { + blendAtt.blendEnable = VK_TRUE; + blendAtt.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA; + blendAtt.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + blendAtt.colorBlendOp = VK_BLEND_OP_ADD; + blendAtt.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + blendAtt.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + blendAtt.alphaBlendOp = VK_BLEND_OP_ADD; + ds.depthWriteEnable = VK_FALSE; + } + const bool hasColor = d.color != VK_FORMAT_UNDEFINED; + VkPipelineColorBlendStateCreateInfo blend{VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO}; + VkPipelineColorBlendAttachmentState atts[2] = {blendAtt, {}}; + atts[1].colorWriteMask = d.gbuf == 2 ? 0xF : 0; + blend.attachmentCount = hasColor ? (d.gbuf ? 2 : 1) : 0; + blend.pAttachments = atts; + VkDynamicState dynStates[] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}; + VkPipelineDynamicStateCreateInfo dyn{VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO}; + dyn.dynamicStateCount = 2; + dyn.pDynamicStates = dynStates; + VkPipelineRenderingCreateInfo rendering{VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO}; + const VkFormat formats[2] = {d.color, kGbufFormat}; + rendering.colorAttachmentCount = hasColor ? (d.gbuf ? 2 : 1) : 0; + rendering.pColorAttachmentFormats = hasColor ? formats : nullptr; + rendering.depthAttachmentFormat = d.depth ? kDepthFormat : VK_FORMAT_UNDEFINED; + + VkGraphicsPipelineCreateInfo pci{VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO}; + pci.pNext = &rendering; + pci.stageCount = fs ? 2 : 1; + pci.pStages = stages; + pci.pVertexInputState = &vertexInput; + pci.pInputAssemblyState = &ia; + pci.pViewportState = &vp; + pci.pRasterizationState = &rs; + pci.pMultisampleState = &ms; + pci.pDepthStencilState = &ds; + pci.pColorBlendState = &blend; + pci.pDynamicState = &dyn; + pci.layout = layout; + VkPipeline p; + VK_CHECK(vkCreateGraphicsPipelines(dev, VK_NULL_HANDLE, 1, &pci, nullptr, &p)); + vkDestroyShaderModule(dev, vs, nullptr); + if (fs) vkDestroyShaderModule(dev, fs, nullptr); + return p; +} + +class VulkanBackend final : public RenderBackend { +public: + const char* name() const override { return "Vulkan 1.3"; } + GpuInfo gpuInfo() const override { + GpuInfo g{device_.gpuName(), device_.gpuDriver()}; + device_.vram(g.vramTotal, g.vramUsed, g.vramOurs); + return g; + } + + bool init(const Window& window, bool validation) override { + if (!device_.init(window, validation)) return false; + swapFormat_ = device_.swapchainFormat(); + createBindless(); + createPipelines(); + // GPU timing (r_profile): timestamps between passes, one set per frame in flight. + VkQueryPoolCreateInfo qi{VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO}; + qi.queryType = VK_QUERY_TYPE_TIMESTAMP; + qi.queryCount = vk::Device::kFramesInFlight * kMaxMarks; + vkCreateQueryPool(device_.device(), &qi, nullptr, &queries_); + VkPhysicalDeviceProperties props; + vkGetPhysicalDeviceProperties(device_.physicalDevice(), &props); + tickNs_ = props.limits.timestampPeriod; + return initImGui(window); + } + + // ── GPU timing ── + static constexpr uint32_t kMaxMarks = 16; + VkQueryPool queries_ = VK_NULL_HANDLE; + float tickNs_ = 1.0f; + const char* markNames_[vk::Device::kFramesInFlight][kMaxMarks] = {}; + uint32_t markCount_[vk::Device::kFramesInFlight] = {}; + std::vector> gpuTimes_; + // This slot's last results (its fence has passed), then start it again. + void beginMarks(VkCommandBuffer cmd) { + const uint32_t fi = device_.frameIndex(), n = markCount_[fi]; + if (n > 1) { + uint64_t t[kMaxMarks]; + if (vkGetQueryPoolResults(device_.device(), queries_, fi * kMaxMarks, n, sizeof t, t, sizeof(uint64_t), VK_QUERY_RESULT_64_BIT) == VK_SUCCESS) { + gpuTimes_.clear(); + for (uint32_t i = 1; i < n; ++i) gpuTimes_.push_back({markNames_[fi][i], float(double(t[i] - t[i - 1]) * tickNs_ * 1e-6)}); + } + } + vkCmdResetQueryPool(cmd, queries_, fi * kMaxMarks, kMaxMarks); + markCount_[fi] = 0; + mark(cmd, "start"); + } + // The time now on the GPU; the span since the last mark is called `name`. + void mark(VkCommandBuffer cmd, const char* name) { + const uint32_t fi = device_.frameIndex(); + if (!queries_ || markCount_[fi] >= kMaxMarks) return; + markNames_[fi][markCount_[fi]] = name; + vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, queries_, fi * kMaxMarks + markCount_[fi]++); + } + void gpuTimes(std::vector>& out) const override { out = gpuTimes_; } + + void shutdown() override { + if (!device_.device()) return; + device_.waitIdle(); + if (queries_) vkDestroyQueryPool(device_.device(), queries_, nullptr), queries_ = VK_NULL_HANDLE; + if (imgui_) { + ImGui_ImplVulkan_Shutdown(); + ImGui_ImplSDL3_Shutdown(); + ImGui::DestroyContext(); + imgui_ = false; + } + VkDevice dev = device_.device(); + destroyTexture(scene_); + destroyTexture(depth_); + for (Texture* t : {&mirror_, &mirrorGbuf_, &mirrorDepth_}) destroyTexture(*t); + for (auto& b : mirrorFrames_) device_.destroyBuffer(b); + for (auto& b : smoke_) device_.destroyBuffer(b); + destroyTexture(hzb_); + for (Texture& t : hzbMips_) destroyTexture(t); + destroyTexture(ao_); + destroyTexture(gbuf_); + destroyTexture(ssr_); + for (Texture& t : adapt_) destroyTexture(t); + destroyTexture(aoBlur_); + for (Texture& t : bloom_) destroyTexture(t); + for (Texture& t : taa_) destroyTexture(t); + for (auto& b : hzbReadback_) device_.destroyBuffer(b); + for (Texture& t : textures_) destroyTexture(t); + for (auto& [frame, m] : retired_) destroyModel(m); + for (auto& [frame, t] : retiredTextures_) destroyTexture(t); + for (Model& m : models_) destroyModel(m); + for (vk::Buffer* b : {&froxelCells_[0], &froxelCells_[1], &froxelOut_, &smokeSky_, &giProbes_[0], &giProbes_[1]}) device_.destroyBuffer(*b); + for (Tlas& t : tlas_) destroyTlas(t); + for (vk::Buffer* list : {jointBuffers_, drawBuffers_, frameBuffers_, lightBuffers_, cullBoxes_, cullCmds_, cullCounter_}) + for (uint32_t i = 0; i < vk::Device::kFramesInFlight; ++i) device_.destroyBuffer(list[i]); + destroyTexture(shadowMap_); + destroyTexture(shadowAtlas_); + for (VkPipeline p : {meshBlendPipeline_, skinnedBlendPipeline_, boxPipeline_, meshPipeline_, skinnedPipeline_, shadowMeshPipeline_, shadowSkinnedPipeline_, cullPipeline_, froxelPipeline_, froxelRtPipeline_, giPipeline_, depthMeshPipeline_, depthSkinnedPipeline_, meshAfterDepthPipeline_, skinnedAfterDepthPipeline_, + tonemapPipeline_, skyPipeline_, hzbPipeline_, ssaoPipeline_, ssaoBlurPipeline_, bloomDownPipeline_, bloomUpPipeline_, taaPipeline_, ssrPipeline_, adaptPipeline_, volumetricPipeline_, volumetricAddPipeline_, spritePipeline_, glowPipeline_, smokeSkyPipeline_}) + vkDestroyPipeline(dev, p, nullptr); + vkDestroyPipelineLayout(dev, layout_, nullptr); + vkDestroyDescriptorPool(dev, pool_, nullptr); + vkDestroyDescriptorSetLayout(dev, setLayout_, nullptr); + vkDestroySampler(dev, sampler_, nullptr); + vkDestroySampler(dev, shadowSampler_, nullptr); + vkDestroySampler(dev, clampSampler_, nullptr); + device_.shutdown(); + } + + void beginUi() override { + ImGui_ImplVulkan_NewFrame(); + ImGui_ImplSDL3_NewFrame(); + ImGui::NewFrame(); + } + void setVsync(bool on) override { device_.setVsync(on); } + void onResize() override { device_.markResized(); } + + bool renderScale() const override { return true; } + bool blockCompression() const override { + VkFormatProperties p{}; + vkGetPhysicalDeviceFormatProperties(device_.physicalDevice(), VK_FORMAT_BC7_SRGB_BLOCK, &p); + return p.optimalTilingFeatures & VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT; + } + + void createTexture(TextureId slot, uint32_t w, uint32_t h, bool srgb, const std::vector>& mips, + TexFormat fmt = TexFormat::RGBA8) override { + const VkFormat format = fmt == TexFormat::BC7 ? (srgb ? VK_FORMAT_BC7_SRGB_BLOCK : VK_FORMAT_BC7_UNORM_BLOCK) + : (srgb ? VK_FORMAT_R8G8B8A8_SRGB : VK_FORMAT_R8G8B8A8_UNORM); + Texture t; + VkImageCreateInfo ici{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO}; + ici.imageType = VK_IMAGE_TYPE_2D; + ici.format = format; + ici.extent = {w, h, 1}; + ici.mipLevels = uint32_t(mips.size()); + ici.arrayLayers = 1; + ici.samples = VK_SAMPLE_COUNT_1_BIT; + ici.usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT; + VmaAllocationCreateInfo aci{}; + aci.usage = VMA_MEMORY_USAGE_AUTO; + VK_CHECK(vmaCreateImage(device_.allocator(), &ici, &aci, &t.image, &t.alloc, nullptr)); + size_t total = 0; + for (auto& m : mips) total += m.size(); + vk::Buffer staging = device_.createBuffer(total, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, true); + std::vector copies; + size_t offset = 0; + for (uint32_t level = 0; level < mips.size(); ++level) { + std::memcpy((uint8_t*)staging.mapped + offset, mips[level].data(), mips[level].size()); + VkBufferImageCopy c{}; + c.bufferOffset = offset; + c.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, level, 0, 1}; + c.imageExtent = {std::max(1u, w >> level), std::max(1u, h >> level), 1}; + copies.push_back(c); + offset += mips[level].size(); + } + device_.immediateSubmit([&](VkCommandBuffer cmd) { + vk::transitionImage(cmd, t.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL); + vkCmdCopyBufferToImage(cmd, staging.buffer, t.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, uint32_t(copies.size()), copies.data()); + vk::transitionImage(cmd, t.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + }); + device_.destroyBuffer(staging); + VkImageViewCreateInfo vci{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO}; + vci.image = t.image; + vci.viewType = VK_IMAGE_VIEW_TYPE_2D; + vci.format = format; + vci.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, uint32_t(mips.size()), 0, 1}; + VK_CHECK(vkCreateImageView(device_.device(), &vci, nullptr, &t.view)); + if (textures_.size() <= slot) textures_.resize(slot + 1); + // Replaced (hot reload): frames in flight may still sample the old one. + if (textures_[slot].image) retiredTextures_.emplace_back(frameNumber_, textures_[slot]); + textures_[slot] = t; + bind(slot, t.view); + } + + void uploadModel(uint32_t index, const KmdlModel& kmdl) override { + if (models_.size() <= index) models_.resize(index + 1); + if (models_[index].vertices.buffer) retired_.emplace_back(frameNumber_, models_[index]); + Model m; + if (!kmdl.vertices.empty() && !kmdl.indices.empty()) { + const VkBufferUsageFlags rt = device_.rayQuery() ? VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR : 0; + m.vertices = device_.uploadBuffer(kmdl.vertices.data(), kmdl.vertices.size() * sizeof(KmdlVertex), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | rt); + m.indices = device_.uploadBuffer(kmdl.indices.data(), kmdl.indices.size() * sizeof(uint32_t), VK_BUFFER_USAGE_INDEX_BUFFER_BIT | rt); + if (device_.rayQuery() && kmdl.skin.empty()) buildBlas(m, kmdl); + if (!kmdl.skin.empty()) + m.skin = device_.uploadBuffer(kmdl.skin.data(), kmdl.skin.size() * sizeof(KmdlSkin), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); + } + models_[index] = m; + } + + uint64_t uiTexture(TextureId slot) override { + if (slot >= textures_.size() || !textures_[slot].view) return 0; + auto it = uiTextures_.find(slot); + if (it != uiTextures_.end() && it->second.view != textures_[slot].view) { // (the slot's picture was replaced: a new set, the old one let go later) + retiredUiSets_.push_back({frameNumber_, it->second.set}); + uiTextures_.erase(it), it = uiTextures_.end(); + } + if (it == uiTextures_.end()) + it = uiTextures_.emplace(slot, UiTexture{uint64_t(ImGui_ImplVulkan_AddTexture(sampler_, textures_[slot].view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)), textures_[slot].view}).first; + return it->second.set; + } + + bool beginFrame(uint32_t& width, uint32_t& height) override { + cmd_ = device_.beginFrame(); + ++frameNumber_; + // Models replaced a few frames ago: no frame in flight uses their buffers any more. + std::erase_if(retiredTextures_, [&](auto& r) { + if (frameNumber_ - r.first <= vk::Device::kFramesInFlight + 1) return false; + destroyTexture(r.second); + return true; + }); + std::erase_if(retiredUiSets_, [&](auto& r) { + if (frameNumber_ - r.first <= vk::Device::kFramesInFlight + 1) return false; + ImGui_ImplVulkan_RemoveTexture(VkDescriptorSet(r.second)); + return true; + }); + std::erase_if(retired_, [&](auto& r) { + if (frameNumber_ - r.first <= vk::Device::kFramesInFlight + 1) return false; + destroyModel(r.second); + return true; + }); + if (!cmd_) return false; + // This slot's last frame has finished (its fence passed): its occlusion grid is ready. + if (const uint32_t fi = device_.frameIndex(); hzbTags_[fi]) { + vmaInvalidateAllocation(device_.allocator(), hzbReadback_[fi].alloc, 0, VK_WHOLE_SIZE); + const float* d = static_cast(hzbReadback_[fi].mapped); + if (hzbTags_[fi] > hzbLatest_.tag) hzbLatest_ = {hzbTags_[fi], std::vector(d, d + kHzbWidth * kHzbHeight)}; + hzbTags_[fi] = 0; + } + const VkExtent2D e = device_.extent(); // (targets are sized to the scene in drawFrame) + width = e.width; + height = e.height; + return true; + } + + void render(FramePacket& p) override; + + bool takeHzb(HzbReadback& out) override { + if (hzbLatest_.tag <= hzbTaken_) return false; + out = hzbLatest_; + hzbTaken_ = hzbLatest_.tag; + return true; + } + +private: + struct Texture { + VkImage image = VK_NULL_HANDLE; + VmaAllocation alloc = VK_NULL_HANDLE; + VkImageView view = VK_NULL_HANDLE; + }; + // A ray-tracing shape (bottom-level acceleration structure): one per submesh, by first index. + struct Blas { + VkAccelerationStructureKHR as = VK_NULL_HANDLE; + vk::Buffer buffer; + VkDeviceAddress address = 0; + }; + struct Model { + vk::Buffer vertices, indices, skin; // skin: KmdlSkin per vertex, skinned models only + std::vector> blas; // with ray queries + }; + + void destroyTexture(Texture& t) { + if (t.view) vkDestroyImageView(device_.device(), t.view, nullptr); + if (t.image) vmaDestroyImage(device_.allocator(), t.image, t.alloc); + t = {}; + } + void destroyModel(Model& m) { + for (auto& [first, b] : m.blas) { + vkDestroyAccelerationStructureKHR(device_.device(), b.as, nullptr); + device_.destroyBuffer(b.buffer); + } + m.blas.clear(); + device_.destroyBuffer(m.vertices); + device_.destroyBuffer(m.indices); + device_.destroyBuffer(m.skin); + } + // Each submesh's ray-tracing shape, built once (now, waiting for it). + void buildBlas(Model& m, const KmdlModel& kmdl) { + VkDevice dev = device_.device(); + std::vector geoms; + std::vector infos; + std::vector ranges; + std::vector scratch; + geoms.reserve(kmdl.submeshes.size()); + for (const KmdlSubmesh& sm : kmdl.submeshes) { + if (sm.indexCount < 3) continue; + VkAccelerationStructureGeometryKHR g{VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR}; + g.geometryType = VK_GEOMETRY_TYPE_TRIANGLES_KHR; + g.flags = VK_GEOMETRY_OPAQUE_BIT_KHR; + auto& t = g.geometry.triangles; + t.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_TRIANGLES_DATA_KHR; + t.vertexFormat = VK_FORMAT_R32G32B32_SFLOAT; + t.vertexData.deviceAddress = m.vertices.address; + t.vertexStride = sizeof(KmdlVertex); + t.maxVertex = uint32_t(kmdl.vertices.size() - 1); + t.indexType = VK_INDEX_TYPE_UINT32; + t.indexData.deviceAddress = m.indices.address; + geoms.push_back(g); + VkAccelerationStructureBuildGeometryInfoKHR bi{VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_GEOMETRY_INFO_KHR}; + bi.type = VK_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL_KHR; + bi.flags = VK_BUILD_ACCELERATION_STRUCTURE_PREFER_FAST_TRACE_BIT_KHR; + bi.mode = VK_BUILD_ACCELERATION_STRUCTURE_MODE_BUILD_KHR; + bi.geometryCount = 1; + bi.pGeometries = &geoms.back(); + const uint32_t prims = sm.indexCount / 3; + VkAccelerationStructureBuildSizesInfoKHR sz{VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_SIZES_INFO_KHR}; + vkGetAccelerationStructureBuildSizesKHR(dev, VK_ACCELERATION_STRUCTURE_BUILD_TYPE_DEVICE_KHR, &bi, &prims, &sz); + Blas b; + b.buffer = device_.createBuffer(sz.accelerationStructureSize, VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_STORAGE_BIT_KHR, false); + VkAccelerationStructureCreateInfoKHR ci{VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_CREATE_INFO_KHR}; + ci.buffer = b.buffer.buffer; + ci.size = sz.accelerationStructureSize; + ci.type = VK_ACCELERATION_STRUCTURE_TYPE_BOTTOM_LEVEL_KHR; + VK_CHECK(vkCreateAccelerationStructureKHR(dev, &ci, nullptr, &b.as)); + VkAccelerationStructureDeviceAddressInfoKHR ai{VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_DEVICE_ADDRESS_INFO_KHR}; + ai.accelerationStructure = b.as; + b.address = vkGetAccelerationStructureDeviceAddressKHR(dev, &ai); + scratch.push_back(device_.createBuffer(sz.buildScratchSize + scratchAlign_, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, false)); + bi.dstAccelerationStructure = b.as; + bi.scratchData.deviceAddress = alignUp(scratch.back().address, scratchAlign_); + infos.push_back(bi); + ranges.push_back({prims, sm.firstIndex * 4u, 0, 0}); + m.blas.emplace_back(sm.firstIndex, b); + } + if (infos.empty()) return; + std::vector rangePtrs; + for (auto& r : ranges) rangePtrs.push_back(&r); + device_.immediateSubmit([&](VkCommandBuffer cmd) { vkCmdBuildAccelerationStructuresKHR(cmd, uint32_t(infos.size()), infos.data(), rangePtrs.data()); }); + for (auto& b : scratch) device_.destroyBuffer(b); + } + static VkDeviceAddress alignUp(VkDeviceAddress a, VkDeviceAddress align) { return (a + align - 1) / align * align; } + + void bind(TextureId slot, VkImageView view) { + VkDescriptorImageInfo info{VK_NULL_HANDLE, view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL}; + VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + w.dstSet = set_; + w.dstBinding = 0; + w.dstArrayElement = slot; + w.descriptorCount = 1; + w.descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE; + w.pImageInfo = &info; + vkUpdateDescriptorSets(device_.device(), 1, &w, 0, nullptr); + } + Texture makeTarget(VkFormat format, VkExtent2D extent, VkImageUsageFlags usage, VkImageAspectFlags aspect) { + Texture t; + VkImageCreateInfo ici{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO}; + ici.imageType = VK_IMAGE_TYPE_2D; + ici.format = format; + ici.extent = {extent.width, extent.height, 1}; + ici.mipLevels = 1; + ici.arrayLayers = 1; + ici.samples = VK_SAMPLE_COUNT_1_BIT; + ici.usage = usage; + VmaAllocationCreateInfo aci{}; + aci.usage = VMA_MEMORY_USAGE_AUTO; + aci.flags = VMA_ALLOCATION_CREATE_DEDICATED_MEMORY_BIT; + VK_CHECK(vmaCreateImage(device_.allocator(), &ici, &aci, &t.image, &t.alloc, nullptr)); + VkImageViewCreateInfo vci{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO}; + vci.image = t.image; + vci.viewType = VK_IMAGE_VIEW_TYPE_2D; + vci.format = format; + vci.subresourceRange = {aspect, 0, 1, 0, 1}; + VK_CHECK(vkCreateImageView(device_.device(), &vci, nullptr, &t.view)); + return t; + } + void createBindless(); + void createPipelines(); + bool initImGui(const Window& window); + void ensureTargets(VkExtent2D extent); + void drawMeshes(VkCommandBuffer cmd, const std::vector& draws, const glm::mat4& viewProj, bool shadow, VkPipeline mesh, + VkPipeline skinned, const vk::Buffer* indirect = nullptr, bool skipLiquid = false); + void shadowPasses(VkCommandBuffer cmd, Texture& target, uint32_t size, const std::vector& passes); + + vk::Device device_; + // GPU occlusion culling (cull.comp): per frame in flight, the opaque draws' boxes, their + // indirect commands (written by the GPU) and how many it hid. + struct CullBox { glm::vec4 lo, hi; glm::uvec4 draw; }; + struct CullPush { glm::mat4 viewProj; VkDeviceAddress boxes, cmds, counter; uint32_t count, hzb, occlusion, pad; }; + static constexpr uint32_t kMaxCulled = 32768; + vk::Buffer cullBoxes_[vk::Device::kFramesInFlight], cullCmds_[vk::Device::kFramesInFlight], cullCounter_[vk::Device::kFramesInFlight]; + VkPipeline cullPipeline_ = VK_NULL_HANDLE; + bool hzbReady_ = false, hzbNext_ = false; // the grid holds last frame's depth (and is readable) + Texture hzbMips_[kHzbLevels]; // [0] unused (that's hzb_) + uint32_t gpuCulled_ = 0; + const vk::Buffer* cullFor_ = nullptr; // this frame's commands, while the opaque lists are drawn + bool gpuCulling() const override { return true; } + // The fog grid (froxel.comp): two cell buffers (this frame's, last frame's) and the integrated one. + struct FroxelPush { VkDeviceAddress frame, cur, prev, integrated; glm::vec4 proj, prevPos, prevFwd; uint32_t mode, frameIndex; VkDeviceAddress tlas, smoke, smokeSky; glm::vec4 smokeOrigin; }; + struct SmokeSkyPush { VkDeviceAddress frame, sky; }; + vk::Buffer smokeSky_; // the fog grid's columns, smoke alone (smoke_sky.frag) + VkPipeline smokeSkyPipeline_ = VK_NULL_HANDLE; + bool smokeSkyOn_ = false; // this frame + static_assert(sizeof(FroxelPush) == 128); // (the whole push range) + vk::Buffer smoke_[vk::Device::kFramesInFlight]; // the smoke grid, written each frame there is smoke + static constexpr uint32_t kFroxelCount = 160 * 90 * 64; // kFroxels in froxel.glsl + vk::Buffer froxelCells_[2], froxelOut_; + VkPipeline froxelPipeline_ = VK_NULL_HANDLE, froxelRtPipeline_ = VK_NULL_HANDLE; // (rays: sky seen from each cell) + uint32_t froxelFrame_ = 0; + glm::vec4 froxelPrevPos_{0.0f}, froxelPrevFwd_{0.0f}; + bool froxelFog() const override { return true; } + // Real-time GI (gi.comp): the scene for rays (per frame in flight), and the probes (this + // frame's, last frame's). + struct Tlas { + VkAccelerationStructureKHR as = VK_NULL_HANDLE; + vk::Buffer buffer, scratch, instances, info; + VkDeviceAddress address = 0; + uint32_t capacity = 0; + }; + struct RayInst { VkDeviceAddress vertices, indices; uint32_t firstIndex, textures, tint, pad; }; // RayInst in gi.comp + struct GiPush { VkDeviceAddress frame, cur, prev, insts; VkDeviceAddress tlas; uint32_t fresh; float hysteresis; glm::vec4 rotation; }; + static constexpr uint32_t kGiProbes = 24 * 24 * 12; // kGiDims in gi.glsl + static constexpr uint32_t kGiProbeSize = 128; // GiProbe + Tlas tlas_[vk::Device::kFramesInFlight]; + vk::Buffer giProbes_[2]; + VkPipeline giPipeline_ = VK_NULL_HANDLE; + uint32_t giFrame_ = 0, giIdle_ = 0; + bool giFresh_ = true, giOn_ = false; + VkDeviceAddress scratchAlign_ = 256; + bool rayTracing() const override { return giPipeline_ != VK_NULL_HANDLE; } + void destroyTlas(Tlas& t) { + if (t.as) vkDestroyAccelerationStructureKHR(device_.device(), t.as, nullptr); + for (vk::Buffer* b : {&t.buffer, &t.scratch, &t.instances, &t.info}) device_.destroyBuffer(*b); + t = {}; + } + // Room for n instances (this slot's last frame is done with it). + void ensureTlas(Tlas& t, uint32_t n) { + if (t.capacity >= n) return; + destroyTlas(t); + uint32_t cap = 1024; + while (cap < n) cap *= 2; + VkDevice dev = device_.device(); + t.instances = device_.createBuffer(cap * sizeof(VkAccelerationStructureInstanceKHR), VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_BIT_KHR, true); + t.info = device_.createBuffer(cap * sizeof(RayInst), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, true); + VkAccelerationStructureGeometryKHR g{VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR}; + g.geometryType = VK_GEOMETRY_TYPE_INSTANCES_KHR; + g.geometry.instances.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_INSTANCES_DATA_KHR; + VkAccelerationStructureBuildGeometryInfoKHR bi{VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_GEOMETRY_INFO_KHR}; + bi.type = VK_ACCELERATION_STRUCTURE_TYPE_TOP_LEVEL_KHR; + bi.flags = VK_BUILD_ACCELERATION_STRUCTURE_PREFER_FAST_BUILD_BIT_KHR; + bi.geometryCount = 1; + bi.pGeometries = &g; + VkAccelerationStructureBuildSizesInfoKHR sz{VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_SIZES_INFO_KHR}; + vkGetAccelerationStructureBuildSizesKHR(dev, VK_ACCELERATION_STRUCTURE_BUILD_TYPE_DEVICE_KHR, &bi, &cap, &sz); + t.buffer = device_.createBuffer(sz.accelerationStructureSize, VK_BUFFER_USAGE_ACCELERATION_STRUCTURE_STORAGE_BIT_KHR, false); + t.scratch = device_.createBuffer(sz.buildScratchSize + scratchAlign_, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, false); + VkAccelerationStructureCreateInfoKHR ci{VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_CREATE_INFO_KHR}; + ci.buffer = t.buffer.buffer; + ci.size = sz.accelerationStructureSize; + ci.type = VK_ACCELERATION_STRUCTURE_TYPE_TOP_LEVEL_KHR; + VK_CHECK(vkCreateAccelerationStructureKHR(dev, &ci, nullptr, &t.as)); + VkAccelerationStructureDeviceAddressInfoKHR ai{VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_DEVICE_ADDRESS_INFO_KHR}; + ai.accelerationStructure = t.as; + t.address = vkGetAccelerationStructureDeviceAddressKHR(dev, &ai); + t.capacity = cap; + } + uint32_t gpuCulled() const override { return gpuCulled_; } + VkFormat swapFormat_ = VK_FORMAT_UNDEFINED; + VkCommandBuffer cmd_ = VK_NULL_HANDLE; + bool imgui_ = false; + VkDescriptorSetLayout setLayout_ = VK_NULL_HANDLE; + VkDescriptorPool pool_ = VK_NULL_HANDLE; + VkDescriptorSet set_ = VK_NULL_HANDLE; + VkSampler sampler_ = VK_NULL_HANDLE, shadowSampler_ = VK_NULL_HANDLE, clampSampler_ = VK_NULL_HANDLE; + VkPipelineLayout layout_ = VK_NULL_HANDLE; // shared: bindless set + 128 bytes of push constants + VkPipeline boxPipeline_ = VK_NULL_HANDLE, meshPipeline_ = VK_NULL_HANDLE, meshBlendPipeline_ = VK_NULL_HANDLE, + skinnedPipeline_ = VK_NULL_HANDLE, skinnedBlendPipeline_ = VK_NULL_HANDLE, shadowMeshPipeline_ = VK_NULL_HANDLE, shadowSkinnedPipeline_ = VK_NULL_HANDLE, + depthMeshPipeline_ = VK_NULL_HANDLE, depthSkinnedPipeline_ = VK_NULL_HANDLE, meshAfterDepthPipeline_ = VK_NULL_HANDLE, + skinnedAfterDepthPipeline_ = VK_NULL_HANDLE, + tonemapPipeline_ = VK_NULL_HANDLE, skyPipeline_ = VK_NULL_HANDLE, hzbPipeline_ = VK_NULL_HANDLE, ssaoPipeline_ = VK_NULL_HANDLE, + ssaoBlurPipeline_ = VK_NULL_HANDLE, bloomDownPipeline_ = VK_NULL_HANDLE, bloomUpPipeline_ = VK_NULL_HANDLE, + taaPipeline_ = VK_NULL_HANDLE, ssrPipeline_ = VK_NULL_HANDLE, adaptPipeline_ = VK_NULL_HANDLE, + volumetricPipeline_ = VK_NULL_HANDLE, volumetricAddPipeline_ = VK_NULL_HANDLE, + spritePipeline_ = VK_NULL_HANDLE, glowPipeline_ = VK_NULL_HANDLE; + Texture adapt_[2]; + uint32_t adaptCur_ = 0; + bool adaptValid_ = false; // the targets were (re)made: no history yet + Texture taa_[2]; + uint32_t shadowMapSize_ = kShadowSize; // TAA history: written this frame / read from last frame, alternating + uint32_t taaCur_ = 0; + // Post-processing targets (sized with the screen). + Texture ao_, aoBlur_, bloom_[kBloomLevels], gbuf_, ssr_; + VkExtent2D aoExtent_{}, bloomExtent_[kBloomLevels]{}; + // Occlusion grid: rendered, copied to a readback buffer per frame in flight, read when that + // frame's fence has passed. + Texture hzb_; + vk::Buffer hzbReadback_[vk::Device::kFramesInFlight]; + uint64_t hzbTags_[vk::Device::kFramesInFlight] = {}; + HzbReadback hzbLatest_; + uint64_t hzbTaken_ = 0; + Texture shadowMap_, shadowAtlas_, scene_, depth_; + // Mirrors: the reflected scene (and the G-buffer and depth its pass needs), the scene's size. + Texture mirror_, mirrorGbuf_, mirrorDepth_; + vk::Buffer mirrorFrames_[vk::Device::kFramesInFlight]; + VkDeviceAddress drawFrame_ = 0; + bool mirrorValid_ = false; // the mirror target's been drawn (else its layout's undefined) // the frame data drawMeshes gives the shaders (0: this frame's) + bool mirrors() const override { return true; } + VkExtent2D targetExtent_{}; + vk::Buffer drawBuffers_[vk::Device::kFramesInFlight], frameBuffers_[vk::Device::kFramesInFlight], + jointBuffers_[vk::Device::kFramesInFlight], lightBuffers_[vk::Device::kFramesInFlight]; + std::vector textures_; // index = slot + std::vector models_; + std::vector> retired_; // freed after kFramesInFlight more frames + std::vector> retiredTextures_; + uint64_t frameNumber_ = 0; + struct UiTexture { + uint64_t set; + VkImageView view; + }; + std::map uiTextures_; // ImGui descriptor sets, made on first use (again if the picture's replaced) + std::vector> retiredUiSets_; +}; + +void VulkanBackend::createBindless() { + VkDevice dev = device_.device(); + VkSamplerCreateInfo sci{VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO}; + sci.magFilter = sci.minFilter = VK_FILTER_LINEAR; + sci.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR; + sci.addressModeU = sci.addressModeV = sci.addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT; + sci.anisotropyEnable = VK_TRUE; + sci.maxAnisotropy = 16.0f; + sci.maxLod = VK_LOD_CLAMP_NONE; + VK_CHECK(vkCreateSampler(dev, &sci, nullptr, &sampler_)); + VkSamplerCreateInfo shci{VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO}; + shci.magFilter = shci.minFilter = VK_FILTER_LINEAR; + shci.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST; + shci.addressModeU = shci.addressModeV = shci.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + shci.compareEnable = VK_TRUE; + shci.compareOp = VK_COMPARE_OP_LESS_OR_EQUAL; + VK_CHECK(vkCreateSampler(dev, &shci, nullptr, &shadowSampler_)); + // Screen-space passes read render targets: clamped, so their edges never wrap round to the + // opposite side (dark bands along the top and bottom of the screen). + VkSamplerCreateInfo cci = sci; + cci.addressModeU = cci.addressModeV = cci.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + cci.anisotropyEnable = VK_FALSE; + VK_CHECK(vkCreateSampler(dev, &cci, nullptr, &clampSampler_)); + + VkDescriptorSetLayoutBinding bindings[4] = { + {0, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, kMaxTextures, kAllStages, nullptr}, + {1, VK_DESCRIPTOR_TYPE_SAMPLER, 1, kAllStages, &sampler_}, + {2, VK_DESCRIPTOR_TYPE_SAMPLER, 1, kAllStages, &shadowSampler_}, + {3, VK_DESCRIPTOR_TYPE_SAMPLER, 1, kAllStages, &clampSampler_}, + }; + VkDescriptorBindingFlags flags[4] = {VK_DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT | VK_DESCRIPTOR_BINDING_UPDATE_AFTER_BIND_BIT, 0, 0, 0}; + VkDescriptorSetLayoutBindingFlagsCreateInfo fci{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_BINDING_FLAGS_CREATE_INFO}; + fci.bindingCount = 4; + fci.pBindingFlags = flags; + VkDescriptorSetLayoutCreateInfo lci{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + lci.pNext = &fci; + lci.flags = VK_DESCRIPTOR_SET_LAYOUT_CREATE_UPDATE_AFTER_BIND_POOL_BIT; + lci.bindingCount = 4; + lci.pBindings = bindings; + VK_CHECK(vkCreateDescriptorSetLayout(dev, &lci, nullptr, &setLayout_)); + VkDescriptorPoolSize sizes[2] = {{VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, kMaxTextures}, {VK_DESCRIPTOR_TYPE_SAMPLER, 3}}; + VkDescriptorPoolCreateInfo pci{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; + pci.flags = VK_DESCRIPTOR_POOL_CREATE_UPDATE_AFTER_BIND_BIT; + pci.maxSets = 1; + pci.poolSizeCount = 2; + pci.pPoolSizes = sizes; + VK_CHECK(vkCreateDescriptorPool(dev, &pci, nullptr, &pool_)); + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = pool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &setLayout_; + VK_CHECK(vkAllocateDescriptorSets(dev, &ai, &set_)); + VkPushConstantRange range{kAllStages, 0, 128}; + VkPipelineLayoutCreateInfo plci{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + plci.setLayoutCount = 1; + plci.pSetLayouts = &setLayout_; + plci.pushConstantRangeCount = 1; + plci.pPushConstantRanges = ⦥ + VK_CHECK(vkCreatePipelineLayout(dev, &plci, nullptr, &layout_)); + + // Shadow maps for the sun and for dynamic lights, sampled through the table. + constexpr VkImageUsageFlags depthUsage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; + shadowMap_ = makeTarget(kDepthFormat, {kShadowSize, kShadowSize}, depthUsage, VK_IMAGE_ASPECT_DEPTH_BIT); + shadowAtlas_ = makeTarget(kDepthFormat, {kAtlasSize, kAtlasSize}, depthUsage, VK_IMAGE_ASPECT_DEPTH_BIT); + bind(kSunShadowSlot, shadowMap_.view); + bind(kAtlasSlot, shadowAtlas_.view); + hzb_ = makeTarget(VK_FORMAT_R32_SFLOAT, {kHzbWidth, kHzbHeight}, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, + VK_IMAGE_ASPECT_COLOR_BIT); + bind(kHzbSlot, hzb_.view); + for (uint32_t l = 1; l < kHzbLevels; ++l) { // the pyramid above it + hzbMips_[l] = makeTarget(VK_FORMAT_R32_SFLOAT, {std::max(1u, kHzbWidth >> l), std::max(1u, kHzbHeight >> l)}, + VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_IMAGE_ASPECT_COLOR_BIT); + bind(kHzbSlot + l, hzbMips_[l].view); + } + for (auto& b : hzbReadback_) b = device_.createBuffer(kHzbWidth * kHzbHeight * 4, VK_BUFFER_USAGE_TRANSFER_DST_BIT, true, true); + for (uint32_t i = 0; i < vk::Device::kFramesInFlight; ++i) { + drawBuffers_[i] = device_.createBuffer(kMaxDraws * sizeof(gpu::DrawData), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, true); + frameBuffers_[i] = device_.createBuffer(sizeof(gpu::FrameData), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, true); + jointBuffers_[i] = device_.createBuffer(kMaxJointsPerFrame * sizeof(glm::mat4), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, true); + lightBuffers_[i] = device_.createBuffer(kLightBufferSize, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, true); + cullBoxes_[i] = device_.createBuffer(kMaxCulled * sizeof(CullBox), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, true); + cullCmds_[i] = device_.createBuffer(kMaxCulled * sizeof(VkDrawIndexedIndirectCommand), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT, false); + cullCounter_[i] = device_.createBuffer(16, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, true, true); + mirrorFrames_[i] = device_.createBuffer(sizeof(gpu::FrameData), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, true); + smoke_[i] = device_.createBuffer(size_t(kSmokeDims.x) * kSmokeDims.y * kSmokeDims.z * 2, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, true); + } + for (auto& b : froxelCells_) b = device_.createBuffer(kFroxelCount * 16, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, false); + froxelOut_ = device_.createBuffer(kFroxelCount * 8, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, false); + smokeSky_ = device_.createBuffer(160 * 90 * 8, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, false); + if (device_.rayQuery()) { + VkPhysicalDeviceAccelerationStructurePropertiesKHR asp{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ACCELERATION_STRUCTURE_PROPERTIES_KHR}; + VkPhysicalDeviceProperties2 props{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2}; + props.pNext = &asp; + vkGetPhysicalDeviceProperties2(device_.physicalDevice(), &props); + scratchAlign_ = std::max(asp.minAccelerationStructureScratchOffsetAlignment, 1); + for (auto& b : giProbes_) { + b = device_.createBuffer(kGiProbes * kGiProbeSize, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, false); + device_.immediateSubmit([&](VkCommandBuffer cmd) { vkCmdFillBuffer(cmd, b.buffer, 0, VK_WHOLE_SIZE, 0); }); + } + } +} + +void VulkanBackend::createPipelines() { + VkDevice dev = device_.device(); + // The scene pass has two attachments (colour, G-buffer); only opaque meshes write the second. + auto scene = [](PipelineDesc d, int gbuf) { d.gbuf = gbuf; return d; }; + boxPipeline_ = makePipeline(dev, layout_, scene({kBoxVert, sizeof kBoxVert, kBoxFrag, sizeof kBoxFrag, kSceneFormat, true}, 1)); + meshPipeline_ = makePipeline(dev, layout_, scene({kMeshVert, sizeof kMeshVert, kMeshFrag, sizeof kMeshFrag, kSceneFormat, true}, 2)); + meshBlendPipeline_ = makePipeline(dev, layout_, scene({kMeshVert, sizeof kMeshVert, kMeshFrag, sizeof kMeshFrag, kSceneFormat, true, false, true}, 1)); + skinnedPipeline_ = makePipeline(dev, layout_, scene({kSkinnedVert, sizeof kSkinnedVert, kMeshFrag, sizeof kMeshFrag, kSceneFormat, true}, 2)); + skinnedBlendPipeline_ = makePipeline(dev, layout_, scene({kSkinnedVert, sizeof kSkinnedVert, kMeshFrag, sizeof kMeshFrag, kSceneFormat, true, false, true}, 1)); + { + PipelineDesc add{kFullscreenVert, sizeof kFullscreenVert, kVolumetricFrag, sizeof kVolumetricFrag, kSceneFormat, false}; + volumetricAddPipeline_ = makePipeline(dev, layout_, [&] { auto d = add; d.additive = true; return d; }()); + volumetricPipeline_ = makePipeline(dev, layout_, add); + } + { // particles: in the scene pass, depth-tested, not written; blended or added on + PipelineDesc sp{kSpriteVert, sizeof kSpriteVert, kSpriteFrag, sizeof kSpriteFrag, kSceneFormat, true, false, true}; + sp.gbuf = 1; + spritePipeline_ = makePipeline(dev, layout_, sp); + sp.blend = false, sp.glow = true; + glowPipeline_ = makePipeline(dev, layout_, sp); + } + adaptPipeline_ = makePipeline(dev, layout_, {kFullscreenVert, sizeof kFullscreenVert, kAdaptFrag, sizeof kAdaptFrag, kSceneFormat, false}); + ssrPipeline_ = makePipeline(dev, layout_, {kFullscreenVert, sizeof kFullscreenVert, kSsrFrag, sizeof kSsrFrag, kSceneFormat, false}); + shadowMeshPipeline_ = makePipeline(dev, layout_, {kMeshVert, sizeof kMeshVert, nullptr, 0, VK_FORMAT_UNDEFINED, true, true}); + // Compute pipelines (GPU culling, the fog grid), on the same layout. + auto compute = [&](const uint32_t* code, size_t size, VkPipeline& out) { + VkShaderModuleCreateInfo ci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO}; + ci.codeSize = size; + ci.pCode = code; + VkShaderModule m; + VK_CHECK(vkCreateShaderModule(dev, &ci, nullptr, &m)); + VkComputePipelineCreateInfo pi{VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO}; + pi.stage = {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, nullptr, 0, VK_SHADER_STAGE_COMPUTE_BIT, m, "main", nullptr}; + pi.layout = layout_; + VK_CHECK(vkCreateComputePipelines(dev, VK_NULL_HANDLE, 1, &pi, nullptr, &out)); + vkDestroyShaderModule(dev, m, nullptr); + }; + compute(kCullComp, sizeof kCullComp, cullPipeline_); + compute(kFroxelComp, sizeof kFroxelComp, froxelPipeline_); + if (device_.rayQuery()) compute(kGiComp, sizeof kGiComp, giPipeline_), compute(kFroxelRtComp, sizeof kFroxelRtComp, froxelRtPipeline_); + // Depth pre-pass, and the scene's opaque meshes after it. + depthMeshPipeline_ = makePipeline(dev, layout_, {kMeshVert, sizeof kMeshVert, kDepthFrag, sizeof kDepthFrag, VK_FORMAT_UNDEFINED, true}); + depthSkinnedPipeline_ = makePipeline(dev, layout_, {kSkinnedVert, sizeof kSkinnedVert, kDepthFrag, sizeof kDepthFrag, VK_FORMAT_UNDEFINED, true}); + { + PipelineDesc m = scene({kMeshVert, sizeof kMeshVert, kMeshFrag, sizeof kMeshFrag, kSceneFormat, true}, 2); + m.prepassed = true; + meshAfterDepthPipeline_ = makePipeline(dev, layout_, m); + m.vs = kSkinnedVert, m.vsSize = sizeof kSkinnedVert; + skinnedAfterDepthPipeline_ = makePipeline(dev, layout_, m); + } + shadowSkinnedPipeline_ = makePipeline(dev, layout_, {kSkinnedVert, sizeof kSkinnedVert, nullptr, 0, VK_FORMAT_UNDEFINED, true, true}); + { + PipelineDesc sky{kSkyVert, sizeof kSkyVert, kSkyFrag, sizeof kSkyFrag, kSceneFormat, true}; + sky.sky = true; + sky.gbuf = 1; + skyPipeline_ = makePipeline(dev, layout_, sky); + sky.fs = kSmokeSkyFrag, sky.fsSize = sizeof kSmokeSkyFrag, sky.over = true; + smokeSkyPipeline_ = makePipeline(dev, layout_, sky); + } + ssaoPipeline_ = makePipeline(dev, layout_, {kFullscreenVert, sizeof kFullscreenVert, kSsaoFrag, sizeof kSsaoFrag, VK_FORMAT_R8_UNORM, false}); + ssaoBlurPipeline_ = makePipeline(dev, layout_, {kFullscreenVert, sizeof kFullscreenVert, kSsaoBlurFrag, sizeof kSsaoBlurFrag, VK_FORMAT_R8_UNORM, false}); + taaPipeline_ = makePipeline(dev, layout_, {kFullscreenVert, sizeof kFullscreenVert, kTaaFrag, sizeof kTaaFrag, kSceneFormat, false}); + bloomDownPipeline_ = makePipeline(dev, layout_, {kFullscreenVert, sizeof kFullscreenVert, kBloomDownFrag, sizeof kBloomDownFrag, kSceneFormat, false}); + { + PipelineDesc up{kFullscreenVert, sizeof kFullscreenVert, kBloomUpFrag, sizeof kBloomUpFrag, kSceneFormat, false}; + up.additive = true; + bloomUpPipeline_ = makePipeline(dev, layout_, up); + } + hzbPipeline_ = makePipeline(dev, layout_, {kFullscreenVert, sizeof kFullscreenVert, kHzbFrag, sizeof kHzbFrag, VK_FORMAT_R32_SFLOAT, false}); + tonemapPipeline_ = makePipeline(dev, layout_, {kFullscreenVert, sizeof kFullscreenVert, kTonemapFrag, sizeof kTonemapFrag, swapFormat_, false}); +} + +bool VulkanBackend::initImGui(const Window& window) { + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGui::GetIO().IniFilename = nullptr; + ImGui::StyleColorsDark(); + ImGui_ImplVulkan_InitInfo ii{}; + ii.ApiVersion = VK_API_VERSION_1_3; + ii.Instance = device_.instance(); + ii.PhysicalDevice = device_.physicalDevice(); + ii.Device = device_.device(); + ii.QueueFamily = device_.queueFamily(); + ii.Queue = device_.queue(); + ii.DescriptorPoolSize = 2048; // (one per texture the UI shows; out of them, the font gets none: a crash) + ii.MinImageCount = 2; + ii.ImageCount = device_.imageCount(); + ii.UseDynamicRendering = true; + ii.PipelineInfoMain.PipelineRenderingCreateInfo = {VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO}; + ii.PipelineInfoMain.PipelineRenderingCreateInfo.colorAttachmentCount = 1; + ii.PipelineInfoMain.PipelineRenderingCreateInfo.pColorAttachmentFormats = &swapFormat_; + imgui_ = ImGui_ImplSDL3_InitForVulkan(window.handle()) && ImGui_ImplVulkan_Init(&ii); + return imgui_; +} + +void VulkanBackend::ensureTargets(VkExtent2D extent) { + if (scene_.image && targetExtent_.width == extent.width && targetExtent_.height == extent.height) return; + device_.waitIdle(); + destroyTexture(scene_); + destroyTexture(depth_); + scene_ = makeTarget(kSceneFormat, extent, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_IMAGE_ASPECT_COLOR_BIT); + depth_ = makeTarget(kDepthFormat, extent, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_IMAGE_ASPECT_DEPTH_BIT); + targetExtent_ = extent; + bind(kSceneSlot, scene_.view); + bind(kDepthSlot, depth_.view); + for (Texture* t : {&mirror_, &mirrorGbuf_, &mirrorDepth_}) destroyTexture(*t); + mirror_ = makeTarget(kSceneFormat, extent, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_IMAGE_ASPECT_COLOR_BIT); + mirrorGbuf_ = makeTarget(kGbufFormat, extent, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT, VK_IMAGE_ASPECT_COLOR_BIT); + mirrorDepth_ = makeTarget(kDepthFormat, extent, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, VK_IMAGE_ASPECT_DEPTH_BIT); + bind(kMirrorSlot, mirror_.view); + mirrorValid_ = false; + // Occlusion at half size; bloom from half down to 1/64. + destroyTexture(ao_); + destroyTexture(aoBlur_); + for (Texture& t : bloom_) destroyTexture(t); + for (Texture& t : taa_) destroyTexture(t); + constexpr VkImageUsageFlags target = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; + aoExtent_ = {std::max(1u, extent.width / 2), std::max(1u, extent.height / 2)}; + ao_ = makeTarget(VK_FORMAT_R8_UNORM, aoExtent_, target, VK_IMAGE_ASPECT_COLOR_BIT); + aoBlur_ = makeTarget(VK_FORMAT_R8_UNORM, aoExtent_, target, VK_IMAGE_ASPECT_COLOR_BIT); + bind(kAoSlot, ao_.view); + bind(kAoBlurSlot, aoBlur_.view); + destroyTexture(gbuf_); + destroyTexture(ssr_); + gbuf_ = makeTarget(kGbufFormat, extent, target, VK_IMAGE_ASPECT_COLOR_BIT); + ssr_ = makeTarget(kSceneFormat, aoExtent_, target, VK_IMAGE_ASPECT_COLOR_BIT); + bind(kGbufSlot, gbuf_.view); + bind(kSsrSlot, ssr_.view); + for (uint32_t i = 0; i < 2; ++i) { + destroyTexture(adapt_[i]); + adapt_[i] = makeTarget(kSceneFormat, {1, 1}, target, VK_IMAGE_ASPECT_COLOR_BIT); + adaptValid_ = false; + bind(kAdaptSlot + i, adapt_[i].view); + } + for (uint32_t i = 0; i < 2; ++i) { + taa_[i] = makeTarget(kSceneFormat, extent, target, VK_IMAGE_ASPECT_COLOR_BIT); + bind(kTaaSlot + i, taa_[i].view); + } + for (uint32_t i = 0; i < kBloomLevels; ++i) { + bloomExtent_[i] = {std::max(1u, extent.width >> (i + 1)), std::max(1u, extent.height >> (i + 1))}; + bloom_[i] = makeTarget(kSceneFormat, bloomExtent_[i], target, VK_IMAGE_ASPECT_COLOR_BIT); + bind(kBloomSlot + i, bloom_[i].view); + } +} + +void VulkanBackend::drawMeshes(VkCommandBuffer cmd, const std::vector& draws, const glm::mat4& viewProj, bool shadow, VkPipeline mesh, + VkPipeline skinned, const vk::Buffer* indirect, bool skipLiquid) { + const uint32_t fi = device_.frameIndex(); + VkPipeline bound = VK_NULL_HANDLE; + uint32_t boundModel = UINT32_MAX; + ModelPush mp{}; + mp.viewProj = viewProj; + mp.frame = drawFrame_ ? drawFrame_ : frameBuffers_[fi].address; + for (const MeshDraw& d : draws) { + const Model& m = models_[d.model]; + if (!m.indices.buffer || (skipLiquid && d.liquid)) continue; + const bool isSkinned = d.firstJoint != UINT32_MAX; + VkPipeline want = isSkinned ? skinned : mesh; + if (want != bound) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, want); + bound = want; + } + if (d.model != boundModel) { + vkCmdBindIndexBuffer(cmd, m.indices.buffer, 0, VK_INDEX_TYPE_UINT32); + boundModel = d.model; + } + mp.vertices = m.vertices.address; + mp.skin = isSkinned ? m.skin.address : 0; + mp.joints = isSkinned ? jointBuffers_[fi].address + d.firstJoint * sizeof(glm::mat4) : 0; + mp.draw = drawBuffers_[fi].address + d.draw * sizeof(gpu::DrawData); + mp.textures = d.textures; + mp.tint = d.tint; + mp.material = shadow ? 0 : d.material; + vkCmdPushConstants(cmd, layout_, kAllStages, 0, sizeof mp, &mp); + if (indirect) // GPU-culled: its command says whether it's drawn + vkCmdDrawIndexedIndirect(cmd, indirect->buffer, (&d - draws.data()) * sizeof(VkDrawIndexedIndirectCommand), 1, sizeof(VkDrawIndexedIndirectCommand)); + else + vkCmdDrawIndexed(cmd, d.indexCount, 1, d.firstIndex, 0, 0); + } +} + +void VulkanBackend::shadowPasses(VkCommandBuffer cmd, Texture& target, uint32_t size, const std::vector& passes) { + vk::transitionImage(cmd, target.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL, VK_IMAGE_ASPECT_DEPTH_BIT); + VkRenderingAttachmentInfo att{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO}; + att.imageView = target.view; + att.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL; + att.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + att.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + att.clearValue.depthStencil = {1.0f, 0}; + VkRenderingInfo ri{VK_STRUCTURE_TYPE_RENDERING_INFO}; + ri.renderArea = {{0, 0}, {size, size}}; + ri.layerCount = 1; + ri.pDepthAttachment = &att; + vkCmdBeginRendering(cmd, &ri); + for (const ShadowPass& pass : passes) { + const VkViewport v{float(pass.rect.x), float(pass.rect.y), float(pass.rect.z), float(pass.rect.w), 0, 1}; + const VkRect2D s{{pass.rect.x, pass.rect.y}, {uint32_t(pass.rect.z), uint32_t(pass.rect.w)}}; + vkCmdSetViewport(cmd, 0, 1, &v); + vkCmdSetScissor(cmd, 0, 1, &s); + drawMeshes(cmd, *pass.draws, pass.viewProj, true, shadowMeshPipeline_, shadowSkinnedPipeline_); + } + vkCmdEndRendering(cmd); + vk::transitionImage(cmd, target.image, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_ASPECT_DEPTH_BIT); +} + +void VulkanBackend::render(FramePacket& p) { + VkCommandBuffer cmd = cmd_; + ensureTargets({p.width, p.height}); // the scene's size (render scale), not the window's + const uint32_t fi = device_.frameIndex(); // the frame fence guarantees the GPU is done with these + std::memcpy(drawBuffers_[fi].mapped, p.draws.data(), p.draws.size() * sizeof(gpu::DrawData)); + std::memcpy(jointBuffers_[fi].mapped, p.joints.data(), p.joints.size() * sizeof(glm::mat4)); + auto* lights = static_cast(lightBuffers_[fi].mapped); + std::memcpy(lights + kLightsOffset, p.lights.data(), p.lights.size() * sizeof(gpu::Light)); + std::memcpy(lights + kViewsOffset, p.views.data(), p.views.size() * sizeof(gpu::ShadowView)); + std::memcpy(lights + kCellsOffset, p.clusters->cells.data(), p.clusters->cells.size() * sizeof(glm::uvec2)); + std::memcpy(lights + kIndicesOffset, p.clusters->indices.data(), p.clusters->indices.size() * sizeof(uint32_t)); + const VkDeviceAddress lb = lightBuffers_[fi].address; + p.frame.lights = lb + kLightsOffset; + p.frame.cells = lb + kCellsOffset; + p.frame.lightIndices = lb + kIndicesOffset; + p.frame.shadowViews = lb + kViewsOffset; + p.frame.froxels = froxelOut_.address; + // Real-time GI: on while there are dynamic lights (and a little after, while their light fades). + giIdle_ = p.lights.empty() ? giIdle_ + 1 : 0; + giOn_ = p.frame.gi.x > 0.5f && giPipeline_ && !p.rayInstances.empty() && giIdle_ < 120; + if (giOn_) { + const float spacing = p.frame.gi.y; + const glm::vec3 cell = glm::floor(glm::vec3(p.frame.camPos) / spacing) - glm::vec3(12, 12, 6); + p.frame.giOrigin = glm::vec4(cell, 0.0f); + p.frame.giProbes = giProbes_[giFrame_ & 1u].address; // this frame's (written before the scene reads it) + } else { + p.frame.gi.x = 0.0f; + giFresh_ = true; + } + std::memcpy(frameBuffers_[fi].mapped, &p.frame, sizeof p.frame); + + beginMarks(cmd); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, layout_, 0, 1, &set_, 0, nullptr); + // ── GPU occlusion culling: the opaque draws' commands, before anything draws them ── + cullFor_ = nullptr; + hzbReady_ = hzbNext_, hzbNext_ = false; // last frame made the grid: it's there to test against + if (p.gpuCull && !p.opaque.empty() && p.opaque.size() <= kMaxCulled) { + gpuCulled_ = *static_cast(cullCounter_[fi].mapped); // this slot's last count (its fence has passed) + *static_cast(cullCounter_[fi].mapped) = 0; + auto* boxes = static_cast(cullBoxes_[fi].mapped); + for (size_t i = 0; i < p.opaque.size(); ++i) { + const MeshDraw& d = p.opaque[i]; + boxes[i] = {glm::vec4(d.lo, 0), glm::vec4(d.hi, 0), glm::uvec4(d.indexCount, d.firstIndex, d.lo != d.hi ? 1u : 0u, 0)}; + } + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, layout_, 0, 1, &set_, 0, nullptr); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, cullPipeline_); + const CullPush cp{p.cullViewProj, cullBoxes_[fi].address, cullCmds_[fi].address, cullCounter_[fi].address, uint32_t(p.opaque.size()), kHzbSlot, + p.cullOcclusion && hzbReady_ ? 1u : 0u, 0}; + vkCmdPushConstants(cmd, layout_, kAllStages, 0, sizeof cp, &cp); + vkCmdDispatch(cmd, (uint32_t(p.opaque.size()) + 63) / 64, 1, 1); + VkMemoryBarrier2 mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER_2}; + mb.srcStageMask = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT; + mb.srcAccessMask = VK_ACCESS_2_SHADER_WRITE_BIT; + mb.dstStageMask = VK_PIPELINE_STAGE_2_DRAW_INDIRECT_BIT | VK_PIPELINE_STAGE_2_HOST_BIT; + mb.dstAccessMask = VK_ACCESS_2_INDIRECT_COMMAND_READ_BIT | VK_ACCESS_2_HOST_READ_BIT; + VkDependencyInfo dep{VK_STRUCTURE_TYPE_DEPENDENCY_INFO}; + dep.memoryBarrierCount = 1; + dep.pMemoryBarriers = &mb; + vkCmdPipelineBarrier2(cmd, &dep); + cullFor_ = &cullCmds_[fi]; + mark(cmd, "gpu culling"); + } + if (p.shadowSize != shadowMapSize_) { // r_shadowres changed + device_.waitIdle(); + destroyTexture(shadowMap_); + shadowMap_ = makeTarget(kDepthFormat, {p.shadowSize, p.shadowSize}, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, + VK_IMAGE_ASPECT_DEPTH_BIT); + bind(kSunShadowSlot, shadowMap_.view); + shadowMapSize_ = p.shadowSize; + } + shadowPasses(cmd, shadowMap_, p.shadowSize, p.sunPasses); // always: clears it when there's no sun + if (!p.atlasPasses.empty()) shadowPasses(cmd, shadowAtlas_, kAtlasSize, p.atlasPasses); + mark(cmd, "shadows"); + + // ── Real-time GI: this frame's scene for rays, then every probe traces it (the fog grid + // traces it too: what sky each cell sees) ── + const bool fogRays = p.frame.froxel.x > 0.5f && froxelRtPipeline_ && !p.rayInstances.empty(); + VkDeviceAddress fogTlas = 0; + if (giOn_ || fogRays) { + Tlas& t = tlas_[fi]; + const uint32_t n = uint32_t(std::min(p.rayInstances.size(), 1u << 20)); + ensureTlas(t, n); + auto* inst = static_cast(t.instances.mapped); + auto* info = static_cast(t.info.mapped); + uint32_t used = 0; + for (uint32_t i = 0; i < n; ++i) { + const RayInstance& r = p.rayInstances[i]; + if (r.model >= models_.size()) continue; + const Model& m = models_[r.model]; + const Blas* b = nullptr; + for (auto& [first, blas] : m.blas) + if (first == r.firstIndex) b = &blas; + if (!b) continue; + VkAccelerationStructureInstanceKHR& o = inst[used]; + for (int row = 0; row < 3; ++row) + for (int col = 0; col < 4; ++col) o.transform.matrix[row][col] = r.transform[col][row]; + o.instanceCustomIndex = used; + o.mask = 0xFF; + o.instanceShaderBindingTableRecordOffset = 0; + o.flags = VK_GEOMETRY_INSTANCE_TRIANGLE_FACING_CULL_DISABLE_BIT_KHR | VK_GEOMETRY_INSTANCE_FORCE_OPAQUE_BIT_KHR; + o.accelerationStructureReference = b->address; + info[used] = {m.vertices.address, m.indices.address, r.firstIndex, r.textures, r.tint, 0}; + ++used; + } + VkAccelerationStructureGeometryKHR g{VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_KHR}; + g.geometryType = VK_GEOMETRY_TYPE_INSTANCES_KHR; + g.geometry.instances.sType = VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_GEOMETRY_INSTANCES_DATA_KHR; + g.geometry.instances.data.deviceAddress = t.instances.address; + VkAccelerationStructureBuildGeometryInfoKHR bi{VK_STRUCTURE_TYPE_ACCELERATION_STRUCTURE_BUILD_GEOMETRY_INFO_KHR}; + bi.type = VK_ACCELERATION_STRUCTURE_TYPE_TOP_LEVEL_KHR; + bi.flags = VK_BUILD_ACCELERATION_STRUCTURE_PREFER_FAST_BUILD_BIT_KHR; + bi.mode = VK_BUILD_ACCELERATION_STRUCTURE_MODE_BUILD_KHR; + bi.geometryCount = 1; + bi.pGeometries = &g; + bi.dstAccelerationStructure = t.as; + bi.scratchData.deviceAddress = alignUp(t.scratch.address, scratchAlign_); + const VkAccelerationStructureBuildRangeInfoKHR range{used, 0, 0, 0}; + const VkAccelerationStructureBuildRangeInfoKHR* ranges = ⦥ + vkCmdBuildAccelerationStructuresKHR(cmd, 1, &bi, &ranges); + VkMemoryBarrier2 mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER_2}; + mb.srcStageMask = VK_PIPELINE_STAGE_2_ACCELERATION_STRUCTURE_BUILD_BIT_KHR | VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT | VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT; + mb.srcAccessMask = VK_ACCESS_2_ACCELERATION_STRUCTURE_WRITE_BIT_KHR | VK_ACCESS_2_SHADER_READ_BIT | VK_ACCESS_2_SHADER_WRITE_BIT; + mb.dstStageMask = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT; + mb.dstAccessMask = VK_ACCESS_2_ACCELERATION_STRUCTURE_READ_BIT_KHR | VK_ACCESS_2_SHADER_READ_BIT | VK_ACCESS_2_SHADER_WRITE_BIT; + VkDependencyInfo dep{VK_STRUCTURE_TYPE_DEPENDENCY_INFO}; + dep.memoryBarrierCount = 1; + dep.pMemoryBarriers = &mb; + vkCmdPipelineBarrier2(cmd, &dep); + if (fogRays) fogTlas = t.address; + if (giOn_) { + // A different turn of the ray pattern each frame (random unit quaternion), so the probes + // see all round them over a few frames. + uint32_t h = giFrame_ * 747796405u + 2891336453u; + auto rnd = [&] { h = h * 747796405u + 2891336453u; uint32_t w = ((h >> ((h >> 28u) + 4u)) ^ h) * 277803737u; return float((w >> 22u) ^ w) / 4294967296.0f; }; + const float u1 = rnd(), u2 = rnd() * 6.2831853f, u3 = rnd() * 6.2831853f; + const glm::vec4 rot(std::sqrt(1 - u1) * std::sin(u2), std::sqrt(1 - u1) * std::cos(u2), std::sqrt(u1) * std::sin(u3), std::sqrt(u1) * std::cos(u3)); + const uint32_t cur = giFrame_ & 1u; + const GiPush gp{frameBuffers_[fi].address, giProbes_[cur].address, giProbes_[cur ^ 1u].address, t.info.address, t.address, giFresh_ ? 1u : 0u, 0.9f, rot}; + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, layout_, 0, 1, &set_, 0, nullptr); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, giPipeline_); + vkCmdPushConstants(cmd, layout_, kAllStages, 0, sizeof gp, &gp); + vkCmdDispatch(cmd, kGiProbes, 1, 1); + mb.srcStageMask = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT; + mb.srcAccessMask = VK_ACCESS_2_SHADER_WRITE_BIT; + mb.dstStageMask = VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT; + mb.dstAccessMask = VK_ACCESS_2_SHADER_READ_BIT; + vkCmdPipelineBarrier2(cmd, &dep); + giFresh_ = false; + ++giFrame_; + mark(cmd, "gi probes"); + } + } + + // ── The fog grid: lit and shadowed cells, then each column added up (the scene reads it) ── + if (p.frame.froxel.x > 0.5f) { + auto barrier = [&](VkPipelineStageFlags2 src, VkAccessFlags2 srcAccess, VkPipelineStageFlags2 dst, VkAccessFlags2 dstAccess) { + VkMemoryBarrier2 mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER_2}; + mb.srcStageMask = src, mb.srcAccessMask = srcAccess, mb.dstStageMask = dst, mb.dstAccessMask = dstAccess; + VkDependencyInfo dep{VK_STRUCTURE_TYPE_DEPENDENCY_INFO}; + dep.memoryBarrierCount = 1; + dep.pMemoryBarriers = &mb; + vkCmdPipelineBarrier2(cmd, &dep); + }; + // (Last frame's reads of the grid, and of the cells, are done before they're written.) + barrier(VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT | VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, VK_ACCESS_2_SHADER_READ_BIT | VK_ACCESS_2_SHADER_WRITE_BIT, + VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, VK_ACCESS_2_SHADER_READ_BIT | VK_ACCESS_2_SHADER_WRITE_BIT); + const uint32_t cur = froxelFrame_ & 1u; + // A jitter along the slice that walks the whole of it over a few frames (golden ratio). + const float jitter = std::fmod(0.5f + float(froxelFrame_) * 0.618034f, 1.0f); + FroxelPush fp{frameBuffers_[fi].address, froxelCells_[cur].address, froxelCells_[cur ^ 1u].address, froxelOut_.address, + p.post.proj, froxelPrevPos_, glm::vec4(glm::vec3(froxelPrevFwd_), jitter), 0, froxelFrame_, fogTlas, 0, 0, p.smokeOrigin}; + if (!p.smoke.empty()) { + std::memcpy(smoke_[fi].mapped, p.smoke.data(), std::min(p.smoke.size(), size_t(kSmokeDims.x) * kSmokeDims.y * kSmokeDims.z) * 2); + fp.smoke = smoke_[fi].address; + fp.smokeSky = smokeSky_.address; + } else fp.smokeOrigin.w = -1.0f; + smokeSkyOn_ = !p.smoke.empty(); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, layout_, 0, 1, &set_, 0, nullptr); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, fogTlas ? froxelRtPipeline_ : froxelPipeline_); + vkCmdPushConstants(cmd, layout_, kAllStages, 0, sizeof fp, &fp); + vkCmdDispatch(cmd, 160 / 8, (90 + 7) / 8, 64); + barrier(VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, VK_ACCESS_2_SHADER_WRITE_BIT, VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, VK_ACCESS_2_SHADER_READ_BIT); + fp.mode = 1; + vkCmdPushConstants(cmd, layout_, kAllStages, 0, sizeof fp, &fp); + vkCmdDispatch(cmd, 160 / 8, (90 + 7) / 8, 1); + barrier(VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, VK_ACCESS_2_SHADER_WRITE_BIT, VK_PIPELINE_STAGE_2_FRAGMENT_SHADER_BIT, VK_ACCESS_2_SHADER_READ_BIT); + froxelPrevPos_ = glm::vec4(glm::vec3(p.frame.camPos), 1.0f); + froxelPrevFwd_ = glm::vec4(glm::vec3(p.frame.camFwd), 0.0f); + ++froxelFrame_; + mark(cmd, "fog grid"); + } else { + froxelPrevPos_.w = 0.0f; // (nothing to blend with when it comes back on) + } + + + // ── Mirror: the scene from the reflected eye, cut at the mirror (no pre-pass, no post) ── + if (p.mirror) { + gpu::FrameData mf = p.mirrorFrame; + mf.lights = p.frame.lights, mf.cells = p.frame.cells, mf.lightIndices = p.frame.lightIndices, mf.shadowViews = p.frame.shadowViews; + mf.froxels = p.frame.froxels, mf.giProbes = p.frame.giProbes; + mf.gi = p.frame.gi, mf.giOrigin = p.frame.giOrigin; // as decided here (GI may be off this frame: no probes to read) + std::memcpy(mirrorFrames_[fi].mapped, &mf, sizeof mf); + const VkExtent2D me{p.width, p.height}; + vk::transitionImage(cmd, mirror_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + vk::transitionImage(cmd, mirrorGbuf_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + vk::transitionImage(cmd, mirrorDepth_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL, VK_IMAGE_ASPECT_DEPTH_BIT); + VkRenderingAttachmentInfo mc[2] = {{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO}, {VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO}}; + mc[0].imageView = mirror_.view; + mc[0].imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + mc[0].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + mc[0].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + mc[0].clearValue.color = {{p.clearColor.r, p.clearColor.g, p.clearColor.b, 1.0f}}; + mc[1] = mc[0]; + mc[1].imageView = mirrorGbuf_.view; + mc[1].storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + VkRenderingAttachmentInfo md{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO}; + md.imageView = mirrorDepth_.view; + md.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL; + md.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + md.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + md.clearValue.depthStencil = {1.0f, 0}; + VkRenderingInfo mri{VK_STRUCTURE_TYPE_RENDERING_INFO}; + const VkRect2D ms{{p.mirrorRect.x, p.mirrorRect.y}, {uint32_t(p.mirrorRect.z), uint32_t(p.mirrorRect.w)}}; // only where the mirror is + // The whole target (a smaller render area left it unwritten on NVIDIA); the scissor + // keeps the drawing to the mirror's rectangle. + mri.renderArea = {{0, 0}, me}; + mri.layerCount = 1; + mri.colorAttachmentCount = 2; + mri.pColorAttachments = mc; + mri.pDepthAttachment = &md; + vkCmdBeginRendering(cmd, &mri); + const VkViewport mv{0, 0, float(me.width), float(me.height), 0, 1}; + vkCmdSetViewport(cmd, 0, 1, &mv); + vkCmdSetScissor(cmd, 0, 1, &ms); + drawFrame_ = mirrorFrames_[fi].address; + drawMeshes(cmd, p.mirrorOpaque, p.mirrorViewProj, false, meshPipeline_, skinnedPipeline_); + if (p.frame.sky.x > 0.5f) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, skyPipeline_); + const gpu::SkyPush sp{p.mirrorInvViewProj, p.frame.sunDir, p.frame.sunColor, p.frame.sky, glm::vec4(p.skyCloud, p.skyPhotoSunZ, p.frame.weather.z, p.frame.weather.w)}; + vkCmdPushConstants(cmd, layout_, kAllStages, 0, sizeof sp, &sp); + vkCmdDraw(cmd, 3, 1, 0, 0); + } + drawMeshes(cmd, p.mirrorTranslucent, p.mirrorViewProj, false, meshBlendPipeline_, skinnedBlendPipeline_); + drawFrame_ = 0; + vkCmdEndRendering(cmd); + vk::transitionImage(cmd, mirror_.image, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + mirrorValid_ = true; + mark(cmd, "mirror"); + } else if (!mirrorValid_ && mirror_.image) { // (bound in the table: keep it in a readable layout) + vk::transitionImage(cmd, mirror_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + mirrorValid_ = true; + } + + // ── Scene pass (HDR) ── + const VkExtent2D extent{p.width, p.height}, outExtent{p.outWidth, p.outHeight}; + const VkViewport outViewport{0, 0, float(p.outWidth), float(p.outHeight), 0, 1}; + const VkRect2D outScissor{{0, 0}, outExtent}; + const VkViewport viewport{0, 0, float(p.width), float(p.height), 0, 1}; + const VkRect2D scissor{{0, 0}, extent}; + vk::transitionImage(cmd, scene_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + vk::transitionImage(cmd, gbuf_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + vk::transitionImage(cmd, depth_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL, VK_IMAGE_ASPECT_DEPTH_BIT); + VkRenderingAttachmentInfo colors[2] = {{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO}, {VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO}}; + VkRenderingAttachmentInfo& color = colors[0]; + color.imageView = scene_.view; + color.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + color.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + color.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + color.clearValue.color = {{p.clearColor.r, p.clearColor.g, p.clearColor.b, 1.0f}}; + colors[1] = color; + colors[1].imageView = gbuf_.view; + colors[1].clearValue.color = {{0.5f, 0.5f, 1.0f, 0.0f}}; // nothing to reflect + VkRenderingAttachmentInfo depth{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO}; + depth.imageView = depth_.view; + depth.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL; + depth.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + depth.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + depth.clearValue.depthStencil = {1.0f, 0}; + if (p.prepass) { // depth first (no liquids: they're cut at their surface), then the scene on top of it + VkRenderingAttachmentInfo pd = depth; + pd.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + VkRenderingInfo pri{VK_STRUCTURE_TYPE_RENDERING_INFO}; + pri.renderArea = scissor; + pri.layerCount = 1; + pri.pDepthAttachment = &pd; + vkCmdBeginRendering(cmd, &pri); + vkCmdSetViewport(cmd, 0, 1, &viewport); + vkCmdSetScissor(cmd, 0, 1, &scissor); + drawMeshes(cmd, p.opaque, p.viewProj, false, depthMeshPipeline_, depthSkinnedPipeline_, cullFor_, true); + vkCmdEndRendering(cmd); + mark(cmd, "depth pre-pass"); + depth.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD; + } + VkRenderingInfo ri{VK_STRUCTURE_TYPE_RENDERING_INFO}; + ri.renderArea = scissor; + ri.layerCount = 1; + ri.colorAttachmentCount = 2; + ri.pColorAttachments = colors; + ri.pDepthAttachment = &depth; + vkCmdBeginRendering(cmd, &ri); + vkCmdSetViewport(cmd, 0, 1, &viewport); + vkCmdSetScissor(cmd, 0, 1, &scissor); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, boxPipeline_); + for (const gpu::Box& b : p.boxes) { + const BoxPush bp{p.viewProj, b}; + vkCmdPushConstants(cmd, layout_, kAllStages, 0, sizeof bp, &bp); + vkCmdDraw(cmd, 36, 1, 0, 0); + } + drawMeshes(cmd, p.opaque, p.viewProj, false, p.prepass ? meshAfterDepthPipeline_ : meshPipeline_, p.prepass ? skinnedAfterDepthPipeline_ : skinnedPipeline_, + cullFor_); + if (p.frame.sky.x > 0.5f) { // where nothing was drawn + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, skyPipeline_); + const gpu::SkyPush sp{p.invViewProj, p.frame.sunDir, p.frame.sunColor, p.frame.sky, glm::vec4(p.skyCloud, p.skyPhotoSunZ, p.frame.weather.z, p.frame.weather.w)}; + vkCmdPushConstants(cmd, layout_, kAllStages, 0, sizeof sp, &sp); + vkCmdDraw(cmd, 3, 1, 0, 0); + } + if (smokeSkyOn_ && p.frame.froxel.x > 0.5f) { // smoke in front of the sky + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, smokeSkyPipeline_); + const SmokeSkyPush sp{frameBuffers_[device_.frameIndex()].address, smokeSky_.address}; + vkCmdPushConstants(cmd, layout_, kAllStages, 0, sizeof sp, &sp); + vkCmdDraw(cmd, 3, 1, 0, 0); + } + drawMeshes(cmd, p.translucent, p.viewProj, false, meshBlendPipeline_, skinnedBlendPipeline_); // back to front, blended + for (auto [list, pipe] : {std::pair{&p.sprites, spritePipeline_}, std::pair{&p.glowSprites, glowPipeline_}}) { + if (list->empty()) continue; + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipe); + for (const gpu::Sprite& sp : *list) { + const SpritePush push{p.viewProj, sp}; + vkCmdPushConstants(cmd, layout_, kAllStages, 0, sizeof push, &push); + vkCmdDraw(cmd, 6, 1, 0, 0); + } + } + vkCmdEndRendering(cmd); + vk::transitionImage(cmd, gbuf_.image, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + vk::transitionImage(cmd, depth_.image, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_ASPECT_DEPTH_BIT); + vk::transitionImage(cmd, scene_.image, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + + mark(cmd, "scene"); + // ── Occlusion grid: the depth (only opaque things write it) shrunk and copied for the CPU ── + if (p.hzbTag) { + const uint32_t fi = device_.frameIndex(); + vk::transitionImage(cmd, hzb_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + VkRenderingAttachmentInfo grid{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO}; + grid.imageView = hzb_.view; + grid.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + grid.loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + grid.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + const VkRect2D area{{0, 0}, {kHzbWidth, kHzbHeight}}; + VkRenderingInfo gi{VK_STRUCTURE_TYPE_RENDERING_INFO}; + gi.renderArea = area; + gi.layerCount = 1; + gi.colorAttachmentCount = 1; + gi.pColorAttachments = &grid; + vkCmdBeginRendering(cmd, &gi); + const VkViewport gv{0, 0, float(kHzbWidth), float(kHzbHeight), 0, 1}; + vkCmdSetViewport(cmd, 0, 1, &gv); + vkCmdSetScissor(cmd, 0, 1, &area); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, hzbPipeline_); + const HzbPush hp{kDepthSlot, extent.width, extent.height, kHzbWidth, kHzbHeight}; + vkCmdPushConstants(cmd, layout_, kAllStages, 0, sizeof hp, &hp); + vkCmdDraw(cmd, 3, 1, 0, 0); + vkCmdEndRendering(cmd); + vk::transitionImage(cmd, hzb_.image, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL); + VkBufferImageCopy region{}; + region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + region.imageExtent = {kHzbWidth, kHzbHeight, 1}; + vkCmdCopyImageToBuffer(cmd, hzb_.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, hzbReadback_[fi].buffer, 1, ®ion); + vk::transitionImage(cmd, hzb_.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); // next frame's culling reads it + // The pyramid: each level the farthest depth of the four (or so) under it. + for (uint32_t l = 1; l < kHzbLevels; ++l) { + const uint32_t sw = std::max(1u, kHzbWidth >> (l - 1)), sh = std::max(1u, kHzbHeight >> (l - 1)); + const uint32_t dw = std::max(1u, kHzbWidth >> l), dh = std::max(1u, kHzbHeight >> l); + vk::transitionImage(cmd, hzbMips_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + VkRenderingAttachmentInfo lv{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO}; + lv.imageView = hzbMips_[l].view; + lv.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + lv.loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + lv.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + const VkRect2D la{{0, 0}, {dw, dh}}; + VkRenderingInfo li{VK_STRUCTURE_TYPE_RENDERING_INFO}; + li.renderArea = la; + li.layerCount = 1; + li.colorAttachmentCount = 1; + li.pColorAttachments = &lv; + vkCmdBeginRendering(cmd, &li); + const VkViewport lvp{0, 0, float(dw), float(dh), 0, 1}; + vkCmdSetViewport(cmd, 0, 1, &lvp); + vkCmdSetScissor(cmd, 0, 1, &la); + const HzbPush lp{kHzbSlot + l - 1, sw, sh, dw, dh}; + vkCmdPushConstants(cmd, layout_, kAllStages, 0, sizeof lp, &lp); + vkCmdDraw(cmd, 3, 1, 0, 0); + vkCmdEndRendering(cmd); + vk::transitionImage(cmd, hzbMips_[l].image, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + } + hzbNext_ = true; + VkMemoryBarrier2 toHost{VK_STRUCTURE_TYPE_MEMORY_BARRIER_2}; + toHost.srcStageMask = VK_PIPELINE_STAGE_2_COPY_BIT; + toHost.srcAccessMask = VK_ACCESS_2_TRANSFER_WRITE_BIT; + toHost.dstStageMask = VK_PIPELINE_STAGE_2_HOST_BIT; + toHost.dstAccessMask = VK_ACCESS_2_HOST_READ_BIT; + VkDependencyInfo dep{VK_STRUCTURE_TYPE_DEPENDENCY_INFO}; + dep.memoryBarrierCount = 1; + dep.pMemoryBarriers = &toHost; + vkCmdPipelineBarrier2(cmd, &dep); + hzbTags_[fi] = p.hzbTag; + vkCmdSetViewport(cmd, 0, 1, &viewport); // back to the full screen for the tonemap + vkCmdSetScissor(cmd, 0, 1, &scissor); + } + + mark(cmd, "occlusion grid"); + // ── Post: ambient occlusion, bloom ── + // One full-screen pass into `target` (left ready to sample); load: blend onto what's there. + auto pass = [&](Texture& t, VkExtent2D ext, VkFormat, VkPipeline pipe, const void* push, uint32_t size, bool load) { + vk::transitionImage(cmd, t.image, load ? VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL : VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + VkRenderingAttachmentInfo att{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO}; + att.imageView = t.view; + att.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + att.loadOp = load ? VK_ATTACHMENT_LOAD_OP_LOAD : VK_ATTACHMENT_LOAD_OP_DONT_CARE; + att.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + const VkRect2D area{{0, 0}, ext}; + VkRenderingInfo ri3{VK_STRUCTURE_TYPE_RENDERING_INFO}; + ri3.renderArea = area; + ri3.layerCount = 1; + ri3.colorAttachmentCount = 1; + ri3.pColorAttachments = &att; + vkCmdBeginRendering(cmd, &ri3); + const VkViewport vp{0, 0, float(ext.width), float(ext.height), 0, 1}; + vkCmdSetViewport(cmd, 0, 1, &vp); + vkCmdSetScissor(cmd, 0, 1, &area); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipe); + vkCmdPushConstants(cmd, layout_, kAllStages, 0, size, push); + vkCmdDraw(cmd, 3, 1, 0, 0); + vkCmdEndRendering(cmd); + vk::transitionImage(cmd, t.image, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + }; + const bool ao = p.post.aoStrength > 0.0f, bloom = p.post.bloomStrength > 0.0f, reflections = p.post.ssr > 0.0f, + volumetric = p.frame.volumetric.x > 0.0f && p.frame.froxel.x < 0.5f, // the sun and the dynamic lights (else in the fog grid) + ssr = reflections || volumetric; // (the half-size target both go to) + if (reflections) { // reflections from this frame's scene, added by TAA (or the tonemap) + const SsrPush sp{kDepthSlot, kGbufSlot, kSceneSlot, extent.width, extent.height, aoExtent_.width, aoExtent_.height, p.post.ssr, p.post.proj}; + pass(ssr_, aoExtent_, kSceneFormat, ssrPipeline_, &sp, sizeof sp, false); + } + if (volumetric) { // sunlight in the air, onto the same target + const VolumetricPush vp{frameBuffers_[fi].address, kDepthSlot, extent.width, extent.height, aoExtent_.width, aoExtent_.height, {}, p.post.proj}; + pass(ssr_, aoExtent_, kSceneFormat, reflections ? volumetricAddPipeline_ : volumetricPipeline_, &vp, sizeof vp, reflections); + } + if (ssr) mark(cmd, "reflections + volumetric"); + // TAA first: what follows (bloom, tonemap) uses the resolved image. + TextureId resolved = kSceneSlot; + if (p.post.taa) { + taaCur_ ^= 1u; + const TaaPush tp{p.post.reproject, kSceneSlot, kTaaSlot + (taaCur_ ^ 1u), kDepthSlot, extent.width, extent.height, p.post.taaReset ? 1u : 0u, ssr ? kSsrSlot : 0u, 0}; + pass(taa_[taaCur_], extent, kSceneFormat, taaPipeline_, &tp, sizeof tp, false); + resolved = kTaaSlot + taaCur_; + mark(cmd, "taa"); + } + if (ao) { + const SsaoPush sp{kDepthSlot, extent.width, extent.height, aoExtent_.width, aoExtent_.height, p.post.aoRadius, p.post.aoStrength, 0, p.post.proj}; + pass(ao_, aoExtent_, VK_FORMAT_R8_UNORM, ssaoPipeline_, &sp, sizeof sp, false); + const SsaoBlurPush bp{kAoSlot, kDepthSlot, extent.width, extent.height, aoExtent_.width, aoExtent_.height, 0, 0, p.post.proj}; + pass(aoBlur_, aoExtent_, VK_FORMAT_R8_UNORM, ssaoBlurPipeline_, &bp, sizeof bp, false); + mark(cmd, "ssao"); + } + if (bloom) { + for (uint32_t i = 0; i < kBloomLevels; ++i) { + const VkExtent2D src = i == 0 ? extent : bloomExtent_[i - 1]; + const BloomPush bp{i == 0 ? resolved : kBloomSlot + i - 1, src.width, src.height, bloomExtent_[i].width, bloomExtent_[i].height, i == 0 ? 1u : 0u}; + pass(bloom_[i], bloomExtent_[i], kSceneFormat, bloomDownPipeline_, &bp, sizeof bp, false); + } + const float radius = 1.0f; + uint32_t radiusBits; + std::memcpy(&radiusBits, &radius, 4); + for (uint32_t i = kBloomLevels - 1; i > 0; --i) { + const BloomPush bp{kBloomSlot + i, bloomExtent_[i].width, bloomExtent_[i].height, bloomExtent_[i - 1].width, bloomExtent_[i - 1].height, radiusBits}; + pass(bloom_[i - 1], bloomExtent_[i - 1], kSceneFormat, bloomUpPipeline_, &bp, sizeof bp, true); + } + } + + if (bloom) mark(cmd, "bloom"); + if (p.post.adapt) { // eye adaptation from the smallest bloom level + adaptCur_ ^= 1u; + const AdaptPush ap{kBloomSlot + kBloomLevels - 1, p.post.adaptReset || !adaptValid_ ? 0u : kAdaptSlot + (adaptCur_ ^ 1u), p.post.adaptDt, 1.5f, + p.post.adaptKey, 0.6f, 1.8f, 0}; + pass(adapt_[adaptCur_], {1, 1}, kSceneFormat, adaptPipeline_, &ap, sizeof ap, false); + adaptValid_ = true; + } + + // ── Tonemap to the swapchain, then UI ── + VkRenderingAttachmentInfo swap{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO}; + swap.imageView = device_.currentImageView(); + swap.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + swap.loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + swap.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + VkRenderingInfo ri2{VK_STRUCTURE_TYPE_RENDERING_INFO}; + ri2.renderArea = outScissor; + ri2.layerCount = 1; + ri2.colorAttachmentCount = 1; + ri2.pColorAttachments = &swap; + vkCmdBeginRendering(cmd, &ri2); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, tonemapPipeline_); + vkCmdSetViewport(cmd, 0, 1, &outViewport); + vkCmdSetScissor(cmd, 0, 1, &outScissor); + const TonemapPush tp{resolved, p.exposure, ao ? kAoBlurSlot : 0u, bloom ? kBloomSlot : 0u, p.post.bloomStrength, p.post.saturation, p.post.vignette, p.post.taa ? p.post.sharpen : 0.0f, kDepthSlot, {ssr && !p.post.taa ? kSsrSlot : 0u, p.post.adapt ? kAdaptSlot + adaptCur_ : 0u, 0}, p.post.shafts, p.post.sunColor}; + vkCmdPushConstants(cmd, layout_, kAllStages, 0, sizeof tp, &tp); + vkCmdDraw(cmd, 3, 1, 0, 0); + ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), cmd); + vkCmdEndRendering(cmd); + mark(cmd, "tonemap + ui"); + + vk::Buffer shot; + if (!p.screenshot.empty()) { + shot = device_.createBuffer(VkDeviceSize(outExtent.width) * outExtent.height * 4, VK_BUFFER_USAGE_TRANSFER_DST_BIT, true, true); + vk::transitionImage(cmd, device_.currentImage(), VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL); + VkBufferImageCopy region{}; + region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + region.imageExtent = {outExtent.width, outExtent.height, 1}; + vkCmdCopyImageToBuffer(cmd, device_.currentImage(), VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, shot.buffer, 1, ®ion); + vk::transitionImage(cmd, device_.currentImage(), VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + } + device_.endFrame(); + if (shot.buffer) { + device_.waitIdle(); + vmaInvalidateAllocation(device_.allocator(), shot.alloc, 0, VK_WHOLE_SIZE); + const size_t count = size_t(outExtent.width) * outExtent.height; + std::vector px(static_cast(shot.mapped), static_cast(shot.mapped) + count * 4); + device_.destroyBuffer(shot); + if (swapFormat_ == VK_FORMAT_B8G8R8A8_UNORM || swapFormat_ == VK_FORMAT_B8G8R8A8_SRGB) + for (size_t i = 0; i < count; ++i) std::swap(px[i * 4], px[i * 4 + 2]); + saveScreenshot(p.screenshot, std::move(px), outExtent.width, outExtent.height); + } +} +} // namespace + +std::unique_ptr createVulkanBackend() { return std::make_unique(); } + +} // namespace keel diff --git a/engine/rhi/vulkan/vk_device.cpp b/engine/rhi/vulkan/vk_device.cpp new file mode 100644 index 0000000..90628f7 --- /dev/null +++ b/engine/rhi/vulkan/vk_device.cpp @@ -0,0 +1,356 @@ +#define VMA_IMPLEMENTATION +#define VMA_STATIC_VULKAN_FUNCTIONS 0 +#define VMA_DYNAMIC_VULKAN_FUNCTIONS 1 +#include "rhi/vulkan/vk_device.h" +#include "core/log.h" +#include "platform/window.h" + +#include +#include + +#include +#include + +namespace keel::vk { + +void check(VkResult r, const char* expr, const char* file, int line) { + if (r == VK_SUCCESS) return; + log::error("{} failed with VkResult {} ({}:{})", expr, int(r), file, line); + std::abort(); +} + +void transitionImage(VkCommandBuffer cmd, VkImage image, VkImageLayout from, VkImageLayout to, VkImageAspectFlags aspect) { + VkImageMemoryBarrier2 barrier{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2}; + // Coarse but correct for now; per-pass barriers come with the render graph. + barrier.srcStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT; + barrier.srcAccessMask = VK_ACCESS_2_MEMORY_WRITE_BIT; + barrier.dstStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT; + barrier.dstAccessMask = VK_ACCESS_2_MEMORY_WRITE_BIT | VK_ACCESS_2_MEMORY_READ_BIT; + barrier.oldLayout = from; + barrier.newLayout = to; + barrier.image = image; + barrier.subresourceRange = {aspect, 0, VK_REMAINING_MIP_LEVELS, 0, VK_REMAINING_ARRAY_LAYERS}; + VkDependencyInfo dep{VK_STRUCTURE_TYPE_DEPENDENCY_INFO}; + dep.imageMemoryBarrierCount = 1; + dep.pImageMemoryBarriers = &barrier; + vkCmdPipelineBarrier2(cmd, &dep); +} + +static VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback(VkDebugUtilsMessageSeverityFlagBitsEXT severity, + VkDebugUtilsMessageTypeFlagsEXT, + const VkDebugUtilsMessengerCallbackDataEXT* data, void*) { + if (severity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT) log::error("[vulkan] {}", data->pMessage); + else if (severity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT) log::warn("[vulkan] {}", data->pMessage); + return VK_FALSE; +} + +bool Device::init(const Window& window, bool validation) { + window_ = &window; + if (volkInitialize() != VK_SUCCESS) { + log::error("No Vulkan loader found - is a Vulkan driver installed?"); + return false; + } + + Uint32 extCount = 0; + const char* const* sdlExts = SDL_Vulkan_GetInstanceExtensions(&extCount); + vkb::InstanceBuilder ib(vkGetInstanceProcAddr); + ib.set_app_name("Fry3DX").require_api_version(1, 3, 0).enable_extensions(extCount, sdlExts); + if (validation) ib.request_validation_layers().set_debug_callback(debugCallback); + auto inst = ib.build(); + if (!inst) { + log::error("Vulkan instance: {}", inst.error().message()); + return false; + } + instance_ = inst->instance; + messenger_ = inst->debug_messenger; + volkLoadInstance(instance_); + + if (!SDL_Vulkan_CreateSurface(window.handle(), instance_, nullptr, &surface_)) { + log::error("SDL_Vulkan_CreateSurface: {}", SDL_GetError()); + return false; + } + + VkPhysicalDeviceVulkan13Features f13{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES}; + f13.dynamicRendering = VK_TRUE; + f13.synchronization2 = VK_TRUE; + f13.shaderDemoteToHelperInvocation = VK_TRUE; // `discard` compiles to demote in Vulkan 1.3 SPIR-V + VkPhysicalDeviceVulkan12Features f12{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES}; + f12.bufferDeviceAddress = VK_TRUE; + f12.descriptorIndexing = VK_TRUE; + f12.runtimeDescriptorArray = VK_TRUE; + f12.descriptorBindingPartiallyBound = VK_TRUE; + f12.descriptorBindingSampledImageUpdateAfterBind = VK_TRUE; + f12.shaderSampledImageArrayNonUniformIndexing = VK_TRUE; + VkPhysicalDeviceFeatures f10{}; + f10.samplerAnisotropy = VK_TRUE; + f10.independentBlend = VK_TRUE; // (the G-buffer pass blends its attachments differently) + auto phys = vkb::PhysicalDeviceSelector(*inst) + .set_surface(surface_) + .set_minimum_version(1, 3) + .set_required_features_13(f13) + .set_required_features_12(f12) + .set_required_features(f10) + .prefer_gpu_device_type(vkb::PreferredDeviceType::discrete) + .select(); + if (!phys) { + log::error("No suitable GPU (needs Vulkan 1.3): {}", phys.error().message()); + return false; + } + vkb::PhysicalDevice pd = phys.value(); + physical_ = pd.physical_device; + log::info("GPU: {}", pd.properties.deviceName); + // Ray queries where the GPU has them (not needed: what uses them is skipped without). + VkPhysicalDeviceAccelerationStructureFeaturesKHR asf{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ACCELERATION_STRUCTURE_FEATURES_KHR}; + asf.accelerationStructure = VK_TRUE; + VkPhysicalDeviceRayQueryFeaturesKHR rqf{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_QUERY_FEATURES_KHR}; + rqf.rayQuery = VK_TRUE; + const char* rt[] = {VK_KHR_ACCELERATION_STRUCTURE_EXTENSION_NAME, VK_KHR_RAY_QUERY_EXTENSION_NAME, VK_KHR_DEFERRED_HOST_OPERATIONS_EXTENSION_NAME}; + rayQuery_ = pd.enable_extensions_if_present(3, rt) && pd.enable_extension_features_if_present(asf) && pd.enable_extension_features_if_present(rqf); + log::info("Ray queries: {}", rayQuery_ ? "yes" : "no"); + + memoryBudget_ = pd.enable_extension_if_present(VK_EXT_MEMORY_BUDGET_EXTENSION_NAME); // (real VRAM use, for the debug overlay) + gpuName_ = pd.properties.deviceName; + VkPhysicalDeviceDriverProperties drv{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES}; + VkPhysicalDeviceProperties2 props2{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, &drv}; + vkGetPhysicalDeviceProperties2(pd.physical_device, &props2); + gpuDriver_ = std::string(drv.driverName) + (drv.driverInfo[0] ? std::string(" ") + drv.driverInfo : std::string()); + + auto dev = vkb::DeviceBuilder(pd).build(); + if (!dev) { + log::error("Vulkan device: {}", dev.error().message()); + return false; + } + device_ = dev->device; + volkLoadDevice(device_); + queue_ = dev->get_queue(vkb::QueueType::graphics).value(); + queueFamily_ = dev->get_queue_index(vkb::QueueType::graphics).value(); + + VmaVulkanFunctions fns{}; + fns.vkGetInstanceProcAddr = vkGetInstanceProcAddr; + fns.vkGetDeviceProcAddr = vkGetDeviceProcAddr; + VmaAllocatorCreateInfo aci{}; + aci.flags = VMA_ALLOCATOR_CREATE_BUFFER_DEVICE_ADDRESS_BIT | (memoryBudget_ ? VMA_ALLOCATOR_CREATE_EXT_MEMORY_BUDGET_BIT : 0); + aci.vulkanApiVersion = VK_API_VERSION_1_3; + aci.instance = instance_; + aci.physicalDevice = physical_; + aci.device = device_; + aci.pVulkanFunctions = &fns; + VK_CHECK(vmaCreateAllocator(&aci, &allocator_)); + + for (Frame& f : frames_) { + VkCommandPoolCreateInfo pci{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO}; + pci.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + pci.queueFamilyIndex = queueFamily_; + VK_CHECK(vkCreateCommandPool(device_, &pci, nullptr, &f.pool)); + VkCommandBufferAllocateInfo ai{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO}; + ai.commandPool = f.pool; + ai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + ai.commandBufferCount = 1; + VK_CHECK(vkAllocateCommandBuffers(device_, &ai, &f.cmd)); + VkSemaphoreCreateInfo sci{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO}; + VK_CHECK(vkCreateSemaphore(device_, &sci, nullptr, &f.acquired)); + VkFenceCreateInfo fci{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO}; + fci.flags = VK_FENCE_CREATE_SIGNALED_BIT; + VK_CHECK(vkCreateFence(device_, &fci, nullptr, &f.inFlight)); + } + VkCommandPoolCreateInfo pci{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO}; + pci.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + pci.queueFamilyIndex = queueFamily_; + VK_CHECK(vkCreateCommandPool(device_, &pci, nullptr, &immPool_)); + VkCommandBufferAllocateInfo ai{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO}; + ai.commandPool = immPool_; + ai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + ai.commandBufferCount = 1; + VK_CHECK(vkAllocateCommandBuffers(device_, &ai, &immCmd_)); + VkFenceCreateInfo fci{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO}; + VK_CHECK(vkCreateFence(device_, &fci, nullptr, &immFence_)); + return createSwapchain(); +} + +void Device::immediateSubmit(const std::function& record) { + VK_CHECK(vkResetCommandBuffer(immCmd_, 0)); + VkCommandBufferBeginInfo bi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; + bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + VK_CHECK(vkBeginCommandBuffer(immCmd_, &bi)); + record(immCmd_); + VK_CHECK(vkEndCommandBuffer(immCmd_)); + VkCommandBufferSubmitInfo cbi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO}; + cbi.commandBuffer = immCmd_; + VkSubmitInfo2 si{VK_STRUCTURE_TYPE_SUBMIT_INFO_2}; + si.commandBufferInfoCount = 1; + si.pCommandBufferInfos = &cbi; + VK_CHECK(vkQueueSubmit2(queue_, 1, &si, immFence_)); + VK_CHECK(vkWaitForFences(device_, 1, &immFence_, VK_TRUE, UINT64_MAX)); + VK_CHECK(vkResetFences(device_, 1, &immFence_)); +} + +Buffer Device::createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, bool hostVisible, bool readback) { + VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bci.size = size; + bci.usage = usage | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT; + VmaAllocationCreateInfo aci{}; + aci.usage = VMA_MEMORY_USAGE_AUTO; + if (hostVisible) + aci.flags = (readback ? VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT : VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT) | VMA_ALLOCATION_CREATE_MAPPED_BIT; + Buffer b; + VmaAllocationInfo info{}; + VK_CHECK(vmaCreateBuffer(allocator_, &bci, &aci, &b.buffer, &b.alloc, &info)); + b.mapped = info.pMappedData; + VkBufferDeviceAddressInfo dai{VK_STRUCTURE_TYPE_BUFFER_DEVICE_ADDRESS_INFO}; + dai.buffer = b.buffer; + b.address = vkGetBufferDeviceAddress(device_, &dai); + return b; +} + +void Device::destroyBuffer(Buffer& b) { + if (b.buffer) vmaDestroyBuffer(allocator_, b.buffer, b.alloc); + b = {}; +} + +Buffer Device::uploadBuffer(const void* data, VkDeviceSize size, VkBufferUsageFlags usage) { + Buffer staging = createBuffer(size, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, true); + std::memcpy(staging.mapped, data, size); + Buffer b = createBuffer(size, usage | VK_BUFFER_USAGE_TRANSFER_DST_BIT, false); + immediateSubmit([&](VkCommandBuffer cmd) { + VkBufferCopy copy{0, 0, size}; + vkCmdCopyBuffer(cmd, staging.buffer, b.buffer, 1, ©); + }); + destroyBuffer(staging); + return b; +} + +bool Device::createSwapchain() { + uint32_t w, h; + window_->pixelSize(w, h); + if (w == 0 || h == 0) return false; + + vkDeviceWaitIdle(device_); + VkSwapchainKHR old = swapchain_; + auto sc = vkb::SwapchainBuilder(physical_, device_, surface_) + .set_desired_format({VK_FORMAT_B8G8R8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR}) + .set_desired_present_mode(vsync_ ? VK_PRESENT_MODE_FIFO_KHR : VK_PRESENT_MODE_MAILBOX_KHR) + .add_fallback_present_mode(vsync_ ? VK_PRESENT_MODE_FIFO_KHR : VK_PRESENT_MODE_IMMEDIATE_KHR) + .set_desired_extent(w, h) + .add_image_usage_flags(VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT) + .set_old_swapchain(old) + .build(); + if (!sc) { + log::error("Swapchain: {}", sc.error().message()); + return false; + } + destroySwapchainViews(); + if (old) vkDestroySwapchainKHR(device_, old, nullptr); + + swapchain_ = sc->swapchain; + swapchainFormat_ = sc->image_format; + extent_ = sc->extent; + images_ = sc->get_images().value(); + views_ = sc->get_image_views().value(); + renderDone_.resize(images_.size()); + for (VkSemaphore& s : renderDone_) { + VkSemaphoreCreateInfo sci{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO}; + VK_CHECK(vkCreateSemaphore(device_, &sci, nullptr, &s)); + } + swapchainDirty_ = false; + return true; +} + +void Device::destroySwapchainViews() { + for (VkImageView v : views_) vkDestroyImageView(device_, v, nullptr); + for (VkSemaphore s : renderDone_) vkDestroySemaphore(device_, s, nullptr); + views_.clear(); + renderDone_.clear(); +} + +VkCommandBuffer Device::beginFrame() { + if (swapchainDirty_ && !createSwapchain()) return nullptr; + + Frame& f = frames_[frameIndex_]; + VK_CHECK(vkWaitForFences(device_, 1, &f.inFlight, VK_TRUE, UINT64_MAX)); + VkResult r = vkAcquireNextImageKHR(device_, swapchain_, UINT64_MAX, f.acquired, VK_NULL_HANDLE, &imageIndex_); + if (r == VK_ERROR_OUT_OF_DATE_KHR) { + swapchainDirty_ = true; + return nullptr; + } + if (r != VK_SUBOPTIMAL_KHR) VK_CHECK(r); + VK_CHECK(vkResetFences(device_, 1, &f.inFlight)); + + VK_CHECK(vkResetCommandBuffer(f.cmd, 0)); + VkCommandBufferBeginInfo bi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; + bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + VK_CHECK(vkBeginCommandBuffer(f.cmd, &bi)); + transitionImage(f.cmd, images_[imageIndex_], VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + return f.cmd; +} + +void Device::endFrame() { + Frame& f = frames_[frameIndex_]; + transitionImage(f.cmd, images_[imageIndex_], VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR); + VK_CHECK(vkEndCommandBuffer(f.cmd)); + + VkSemaphoreSubmitInfo wait{VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO}; + wait.semaphore = f.acquired; + wait.stageMask = VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT; + VkSemaphoreSubmitInfo signal{VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO}; + signal.semaphore = renderDone_[imageIndex_]; + signal.stageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT; + VkCommandBufferSubmitInfo cbi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO}; + cbi.commandBuffer = f.cmd; + VkSubmitInfo2 si{VK_STRUCTURE_TYPE_SUBMIT_INFO_2}; + si.waitSemaphoreInfoCount = 1; + si.pWaitSemaphoreInfos = &wait; + si.commandBufferInfoCount = 1; + si.pCommandBufferInfos = &cbi; + si.signalSemaphoreInfoCount = 1; + si.pSignalSemaphoreInfos = &signal; + VK_CHECK(vkQueueSubmit2(queue_, 1, &si, f.inFlight)); + + VkPresentInfoKHR pi{VK_STRUCTURE_TYPE_PRESENT_INFO_KHR}; + pi.waitSemaphoreCount = 1; + pi.pWaitSemaphores = &renderDone_[imageIndex_]; + pi.swapchainCount = 1; + pi.pSwapchains = &swapchain_; + pi.pImageIndices = &imageIndex_; + VkResult r = vkQueuePresentKHR(queue_, &pi); + if (r == VK_ERROR_OUT_OF_DATE_KHR || r == VK_SUBOPTIMAL_KHR) swapchainDirty_ = true; + else VK_CHECK(r); + + frameIndex_ = (frameIndex_ + 1) % kFramesInFlight; +} + +void Device::shutdown() { + if (!device_) return; + vkDeviceWaitIdle(device_); + for (Frame& f : frames_) { + vkDestroyCommandPool(device_, f.pool, nullptr); + vkDestroySemaphore(device_, f.acquired, nullptr); + vkDestroyFence(device_, f.inFlight, nullptr); + } + vkDestroyCommandPool(device_, immPool_, nullptr); + vkDestroyFence(device_, immFence_, nullptr); + destroySwapchainViews(); + vkDestroySwapchainKHR(device_, swapchain_, nullptr); + vmaDestroyAllocator(allocator_); + vkDestroyDevice(device_, nullptr); + vkDestroySurfaceKHR(instance_, surface_, nullptr); + if (messenger_) vkDestroyDebugUtilsMessengerEXT(instance_, messenger_, nullptr); + vkDestroyInstance(instance_, nullptr); + device_ = VK_NULL_HANDLE; +} + +void Device::vram(uint64_t& total, uint64_t& used, uint64_t& ours) const { + total = used = ours = 0; + if (!allocator_) return; + const VkPhysicalDeviceMemoryProperties* mem = nullptr; + vmaGetMemoryProperties(allocator_, &mem); + VmaBudget budgets[VK_MAX_MEMORY_HEAPS]; + vmaGetHeapBudgets(allocator_, budgets); + for (uint32_t h = 0; h < mem->memoryHeapCount; ++h) { + if (!(mem->memoryHeaps[h].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT)) continue; + total += mem->memoryHeaps[h].size; + used += budgets[h].usage; // (the whole process's, with VK_EXT_memory_budget; VMA's own blocks without) + ours += budgets[h].statistics.blockBytes; + } +} + +} // namespace keel::vk diff --git a/engine/rhi/vulkan/vk_device.h b/engine/rhi/vulkan/vk_device.h new file mode 100644 index 0000000..61c7eea --- /dev/null +++ b/engine/rhi/vulkan/vk_device.h @@ -0,0 +1,115 @@ +#pragma once +// Vulkan 1.3 device, swapchain and frames in flight. +// Uses dynamic rendering and synchronization2; no render passes. +#include +#include + +#include +#include +#include + +namespace keel { +class Window; +} + +namespace keel::vk { + +#define VK_CHECK(x) ::keel::vk::check((x), #x, __FILE__, __LINE__) +void check(VkResult r, const char* expr, const char* file, int line); + +void transitionImage(VkCommandBuffer cmd, VkImage image, VkImageLayout from, VkImageLayout to, + VkImageAspectFlags aspect = VK_IMAGE_ASPECT_COLOR_BIT); + +struct Buffer { + VkBuffer buffer = VK_NULL_HANDLE; + VmaAllocation alloc = VK_NULL_HANDLE; + VkDeviceAddress address = 0; + void* mapped = nullptr; // host-visible buffers only +}; + +class Device { +public: + static constexpr uint32_t kFramesInFlight = 2; + + bool init(const Window& window, bool validation); + void shutdown(); + + // Returns a command buffer recording into the current swapchain image (already in + // COLOR_ATTACHMENT_OPTIMAL), or nullptr when no frame can be drawn (minimized, resizing). + VkCommandBuffer beginFrame(); + void endFrame(); + + // Records and runs commands right away, waiting for completion. For loading, not per frame. + void immediateSubmit(const std::function& record); + // hostVisible: mapped CPU-writable memory (staging); otherwise device-local. + // readback: host-cached memory the CPU reads from (screenshots); otherwise write-combined. + Buffer createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, bool hostVisible, bool readback = false); + void destroyBuffer(Buffer& b); + // Device-local buffer filled with `data` through a staging copy. + Buffer uploadBuffer(const void* data, VkDeviceSize size, VkBufferUsageFlags usage); + + void setVsync(bool vsync) { vsync_ = vsync; swapchainDirty_ = true; } + void markResized() { swapchainDirty_ = true; } + void waitIdle() { vkDeviceWaitIdle(device_); } + + VkInstance instance() const { return instance_; } + VkPhysicalDevice physicalDevice() const { return physical_; } + // Hardware ray queries (VK_KHR_ray_query + acceleration structures): real-time GI uses them. + bool rayQuery() const { return rayQuery_; } + VkDevice device() const { return device_; } + VkQueue queue() const { return queue_; } + uint32_t queueFamily() const { return queueFamily_; } + VmaAllocator allocator() const { return allocator_; } + const std::string& gpuName() const { return gpuName_; } + const std::string& gpuDriver() const { return gpuDriver_; } + // VRAM (the device-local heaps): total, used by everyone (ours if the driver can't say), ours. + void vram(uint64_t& total, uint64_t& used, uint64_t& ours) const; + + VkFormat swapchainFormat() const { return swapchainFormat_; } + VkExtent2D extent() const { return extent_; } + uint32_t frameIndex() const { return frameIndex_; } // which frame-in-flight slot is recording + uint32_t imageCount() const { return uint32_t(images_.size()); } + VkImage currentImage() const { return images_[imageIndex_]; } + VkImageView currentImageView() const { return views_[imageIndex_]; } + +private: + struct Frame { + VkCommandPool pool = VK_NULL_HANDLE; + VkCommandBuffer cmd = VK_NULL_HANDLE; + VkSemaphore acquired = VK_NULL_HANDLE; + VkFence inFlight = VK_NULL_HANDLE; + }; + + bool createSwapchain(); + void destroySwapchainViews(); + + const Window* window_ = nullptr; + VkInstance instance_ = VK_NULL_HANDLE; + VkDebugUtilsMessengerEXT messenger_ = VK_NULL_HANDLE; + VkSurfaceKHR surface_ = VK_NULL_HANDLE; + VkPhysicalDevice physical_ = VK_NULL_HANDLE; + bool rayQuery_ = false, memoryBudget_ = false; + std::string gpuName_, gpuDriver_; + VkDevice device_ = VK_NULL_HANDLE; + VkQueue queue_ = VK_NULL_HANDLE; + uint32_t queueFamily_ = 0; + VmaAllocator allocator_ = VK_NULL_HANDLE; + + VkSwapchainKHR swapchain_ = VK_NULL_HANDLE; + VkFormat swapchainFormat_ = VK_FORMAT_B8G8R8A8_UNORM; + VkExtent2D extent_{}; + std::vector images_; + std::vector views_; + std::vector renderDone_; // per swapchain image, so present never reuses one in flight + bool vsync_ = true; + bool swapchainDirty_ = false; + + Frame frames_[kFramesInFlight]; + VkCommandPool immPool_ = VK_NULL_HANDLE; + VkCommandBuffer immCmd_ = VK_NULL_HANDLE; + VkFence immFence_ = VK_NULL_HANDLE; + uint32_t frameIndex_ = 0; + uint32_t imageIndex_ = 0; +}; + +} // namespace keel::vk diff --git a/engine/shaders/adapt.frag b/engine/shaders/adapt.frag new file mode 100644 index 0000000..a97d28d --- /dev/null +++ b/engine/shaders/adapt.frag @@ -0,0 +1,32 @@ +#version 460 +// Eye adaptation (1×1): the scene's average brightness (geometric mean of 16 taps of the +// smallest bloom level), turned into an exposure that keeps a mid grey where it should be, +// eased towards (0.6x to 1.8x, so nothing burns out) from last frame's at `speed` per second (faster into the dark is slower, +// as eyes are). The tonemap multiplies its exposure by it. +#define KEEL_POST // screen-space: render targets, sampled clamped +#include "backend.glsl" +PUSH_BLOCK { + uint src; // the smallest bloom level + uint prev; // last frame's (0 = start fresh) + float dt; + float speed; + float key; // the brightness to aim for + float minE, maxE; + float pad; +} pc; +layout(location = 0) out vec4 o_exposure; + +void main() { + float sum = 0.0; + for (int y = 0; y < 4; ++y) + for (int x = 0; x < 4; ++x) { + vec3 c = sampleTexLod(pc.src, (vec2(x, y) + 0.5) / 4.0, 0.0).rgb; + sum += log(max(dot(c, vec3(0.2126, 0.7152, 0.0722)), 1e-4)); + } + float average = exp(sum / 16.0); + float target = clamp(pc.key / average, pc.minE, pc.maxE); + if (pc.prev == 0u) { o_exposure = vec4(target); return; } + float last = sampleTexLod(pc.prev, vec2(0.5), 0.0).r; + float rate = target > last ? pc.speed * 0.6 : pc.speed; // brightening (into the dark) is slower + o_exposure = vec4(last + (target - last) * (1.0 - exp(-pc.dt * rate))); +} diff --git a/engine/shaders/airlight.glsl b/engine/shaders/airlight.glsl new file mode 100644 index 0000000..251e5da --- /dev/null +++ b/engine/shaders/airlight.glsl @@ -0,0 +1,61 @@ +// The light in the air at a point (volumetric.frag, froxel.comp): the sun through its shadow +// cascades, and the dynamic lights of that point's cluster with a one-tap shadow each. Needs +// FRAME, LIGHT, CELL, LIGHT_INDEX and SHADOW_VIEW (model.glsl with KEEL_FRAME_ONLY). +float sunlitAt(vec3 w) { + float depth = dot(w - FRAME.camPos.xyz, FRAME.camFwd.xyz); + if (depth > FRAME.splits.w) return 1.0; + int c = depth < FRAME.splits.x ? 0 : depth < FRAME.splits.y ? 1 : depth < FRAME.splits.z ? 2 : 3; + vec4 p = FRAME.cascades[c] * vec4(w, 1.0); + vec2 uv = p.xy * 0.5 + 0.5; + if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0))) || p.z > 1.0) return 1.0; + vec2 base = vec2(c & 1, c >> 1) * 0.5; + float t = FRAME.shadow.x; + return shadowTap(uint(FRAME.sunDir.w), base + uv * 0.5, base + t, base + 0.5 - t, p.z - 0.0005); +} + +// The light cluster a point in the air is in (render/clusters.h), from its pixel's place on screen. +const uvec3 kClusters = uvec3(16, 9, 24); +const float kClusterNear = 8.0, kClusterFar = 16384.0; +uint clusterAt(vec3 w, vec2 uv) { + float depth = dot(w - FRAME.camPos.xyz, FRAME.camFwd.xyz); + uint z = depth <= kClusterNear ? 0u : min(uint(log(depth / kClusterNear) / log(kClusterFar / kClusterNear) * float(kClusters.z)), kClusters.z - 1u); + uvec2 tile = min(uvec2(uv * vec2(kClusters.xy)), kClusters.xy - 1u); + return (z * kClusters.y + tile.y) * kClusters.x + tile.x; +} +float phaseHG(float mu, float g) { return (1.0 - g * g) / (12.566 * pow(1.0 + g * g - 2.0 * g * mu, 1.5)); } +// The dynamic lights lighting the air at w (towards the eye along -rd). +vec3 lightsAt(vec3 w, vec3 rd, vec2 uv) { + if (FRAME.camPos.w < 0.5) return vec3(0.0); + uvec2 cell = CELL(clusterAt(w, uv)); + vec3 total = vec3(0.0); + for (uint k = 0u; k < min(cell.y, 8u); ++k) { + Light l = LIGHT(LIGHT_INDEX(cell.x + k)); + vec3 toLight = l.posRadius.xyz - w; + float dist = length(toLight); + if (dist >= l.posRadius.w) continue; + vec3 L = toLight / max(dist, 0.001); + float a = 1.0 - dist / l.posRadius.w, atten = a * a; + if (l.spot.w > -1.5) atten *= spotShape(l, dot(-L, l.spot.xyz)); + if (atten <= 0.0) continue; + if (l.color.w >= 0.0) { // shadowed: one tap in its atlas tile + int view = int(l.color.w); + if (l.spot.w <= -1.5) { + vec3 d = -toLight, ad = abs(d); + view += ad.x >= ad.y && ad.x >= ad.z ? (d.x > 0.0 ? 0 : 1) : ad.y >= ad.z ? (d.y > 0.0 ? 2 : 3) : (d.z > 0.0 ? 4 : 5); + } + ShadowView sv = SHADOW_VIEW(view); + vec4 c = sv.viewProj * vec4(w, 1.0); + if (c.w > 0.0) { + c.xyz /= c.w; + if (all(lessThanEqual(abs(c.xy), vec2(1.0))) && c.z <= 1.0) { + float texel = 1.0 / FRAME.screen.w; + vec2 at = sv.rect.xy + (c.xy * 0.5 + 0.5) * sv.rect.zw; + atten *= shadowTap(uint(FRAME.screen.z), at, sv.rect.xy + texel, sv.rect.xy + sv.rect.zw - texel, c.z - 0.00005); + } + } + } + total += l.color.rgb * atten * phaseHG(dot(rd, -L), 0.3); + } + return total; +} + diff --git a/engine/shaders/backend.glsl b/engine/shaders/backend.glsl new file mode 100644 index 0000000..76d2bec --- /dev/null +++ b/engine/shaders/backend.glsl @@ -0,0 +1,16 @@ +#ifndef KEEL_BACKEND_GLSL +#define KEEL_BACKEND_GLSL +// What every shader includes: textures and the push block, for the backend being built. +// Vulkan: bindless descriptor array + push constants. OpenGL 4.6: ARB_bindless_texture handles +// in storage buffers + a uniform block. Shader code only uses the names defined here. +#ifdef KEEL_GL +#include "backend_gl.glsl" +#else +#include "backend_vk.glsl" +#endif + +float sunLight(vec3 n) { + const vec3 sun = normalize(vec3(0.4, 0.3, 0.85)); + return 0.3 + 0.9 * max(dot(n, sun), 0.0); +} +#endif diff --git a/engine/shaders/backend_gl.glsl b/engine/shaders/backend_gl.glsl new file mode 100644 index 0000000..095731f --- /dev/null +++ b/engine/shaders/backend_gl.glsl @@ -0,0 +1,26 @@ +// OpenGL 4.6 + ARB_bindless_texture (enabled by the loader's preamble): every texture is a +// 64-bit handle (sampler state included) in a table, indexed like Vulkan's descriptor array. +// Depth textures also get a comparison handle in a second table, for shadows. +layout(std430, binding = 9) readonly buffer TextureHandles { uvec2 handles[]; } u_textureHandles; +layout(std430, binding = 10) readonly buffer ShadowHandles { uvec2 handles[]; } u_shadowHandles; + +#define PUSH_BLOCK layout(std140, binding = 0) uniform Push +#define VERTEX_ID gl_VertexID + +vec4 sampleTex(uint index, vec2 uv) { + return texture(sampler2D(u_textureHandles.handles[index]), uv); +} + +ivec2 texSize(uint index) { return textureSize(sampler2D(u_textureHandles.handles[index]), 0); } + +vec4 fetchTex(uint index, ivec2 p) { + return texelFetch(sampler2D(u_textureHandles.handles[index]), p, 0); +} + +vec4 sampleTexLod(uint index, vec2 uv, float lod) { + return textureLod(sampler2D(u_textureHandles.handles[index]), uv, lod); +} + +float shadowTap(uint index, vec2 uv, vec2 lo, vec2 hi, float ref) { + return textureLod(sampler2DShadow(u_shadowHandles.handles[index]), vec3(clamp(uv, lo, hi), ref), 0.0); +} diff --git a/engine/shaders/backend_vk.glsl b/engine/shaders/backend_vk.glsl new file mode 100644 index 0000000..6316730 --- /dev/null +++ b/engine/shaders/backend_vk.glsl @@ -0,0 +1,35 @@ +// Vulkan: one global descriptor array of textures, separate samplers, push constants. +#extension GL_EXT_nonuniform_qualifier : require +#extension GL_EXT_buffer_reference : require +layout(set = 0, binding = 0) uniform texture2D u_textures[]; +layout(set = 0, binding = 1) uniform sampler u_sampler; +layout(set = 0, binding = 2) uniform sampler u_shadowSampler; // depth compare, linear: bilinear PCF +layout(set = 0, binding = 3) uniform sampler u_clampSampler; // render targets, for the screen-space passes +// Post passes (#define KEEL_POST before including): every lookup clamps at the edges. +#ifdef KEEL_POST +#define KEEL_SAMPLER u_clampSampler +#else +#define KEEL_SAMPLER u_sampler +#endif + +#define PUSH_BLOCK layout(push_constant) uniform Push +#define VERTEX_ID gl_VertexIndex + +vec4 sampleTex(uint index, vec2 uv) { + return texture(sampler2D(u_textures[nonuniformEXT(index)], KEEL_SAMPLER), uv); +} + +ivec2 texSize(uint index) { return textureSize(sampler2D(u_textures[nonuniformEXT(index)], KEEL_SAMPLER), 0); } + +vec4 fetchTex(uint index, ivec2 p) { + return texelFetch(sampler2D(u_textures[nonuniformEXT(index)], KEEL_SAMPLER), p, 0); +} + +vec4 sampleTexLod(uint index, vec2 uv, float lod) { + return textureLod(sampler2D(u_textures[nonuniformEXT(index)], KEEL_SAMPLER), uv, lod); +} + +// 1 = lit. Hardware-filtered depth compare at uv (clamped inside [lo, hi]). +float shadowTap(uint index, vec2 uv, vec2 lo, vec2 hi, float ref) { + return textureLod(sampler2DShadow(u_textures[nonuniformEXT(index)], u_shadowSampler), vec3(clamp(uv, lo, hi), ref), 0.0); +} diff --git a/engine/shaders/bloom_down.frag b/engine/shaders/bloom_down.frag new file mode 100644 index 0000000..0dbcb71 --- /dev/null +++ b/engine/shaders/bloom_down.frag @@ -0,0 +1,30 @@ +#version 460 +// Bloom, going down: a 13-tap filter (Jimenez, "Next generation post processing in Call of +// Duty: Advanced Warfare") halving the size each step. The first step clamps fireflies. +#define KEEL_POST // screen-space: render targets, sampled clamped +#include "backend.glsl" +PUSH_BLOCK { + uint src; + uint srcW, srcH; + uint outW, outH; + uint first; +} pc; +layout(location = 0) out vec4 o_color; + +vec3 tap(vec2 uv) { + vec2 h = 0.5 / vec2(pc.srcW, pc.srcH); + return sampleTex(pc.src, clamp(uv, h, 1.0 - h)).rgb; +} + +void main() { + vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH); + vec2 t = 1.0 / vec2(pc.srcW, pc.srcH); + vec3 a = tap(uv + t * vec2(-2, -2)), b = tap(uv + t * vec2(0, -2)), c = tap(uv + t * vec2(2, -2)); + vec3 d = tap(uv + t * vec2(-1, -1)), e = tap(uv + t * vec2(1, -1)); + vec3 f = tap(uv + t * vec2(-2, 0)), g = tap(uv), hh = tap(uv + t * vec2(2, 0)); + vec3 i = tap(uv + t * vec2(-1, 1)), j = tap(uv + t * vec2(1, 1)); + vec3 k = tap(uv + t * vec2(-2, 2)), l = tap(uv + t * vec2(0, 2)), m = tap(uv + t * vec2(2, 2)); + vec3 sum = (d + e + i + j) * 0.125 + (a + c + k + m) * 0.03125 + (b + f + hh + l) * 0.0625 + g * 0.125; + if (pc.first != 0u) sum = min(sum, vec3(32.0)); // no single-pixel sparkle blooming the screen + o_color = vec4(sum, 1.0); +} diff --git a/engine/shaders/bloom_up.frag b/engine/shaders/bloom_up.frag new file mode 100644 index 0000000..5033651 --- /dev/null +++ b/engine/shaders/bloom_up.frag @@ -0,0 +1,25 @@ +#version 460 +// Bloom, coming up: a 3×3 tent of the smaller level, added onto the larger one (blended). +#define KEEL_POST // screen-space: render targets, sampled clamped +#include "backend.glsl" +PUSH_BLOCK { + uint src; + uint srcW, srcH; + uint outW, outH; + float radius; +} pc; +layout(location = 0) out vec4 o_color; + +vec3 tap(vec2 uv) { + vec2 h = 0.5 / vec2(pc.srcW, pc.srcH); + return sampleTex(pc.src, clamp(uv, h, 1.0 - h)).rgb; +} + +void main() { + vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH); + vec2 t = pc.radius / vec2(pc.srcW, pc.srcH); + vec3 s = tap(uv) * 4.0; + s += (tap(uv + vec2(-t.x, 0)) + tap(uv + vec2(t.x, 0)) + tap(uv + vec2(0, -t.y)) + tap(uv + vec2(0, t.y))) * 2.0; + s += tap(uv + vec2(-t.x, -t.y)) + tap(uv + vec2(t.x, -t.y)) + tap(uv + vec2(-t.x, t.y)) + tap(uv + vec2(t.x, t.y)); + o_color = vec4(s / 16.0, 1.0); +} diff --git a/engine/shaders/box.frag b/engine/shaders/box.frag new file mode 100644 index 0000000..357040c --- /dev/null +++ b/engine/shaders/box.frag @@ -0,0 +1,20 @@ +#version 460 +#include "backend.glsl" +PUSH_BLOCK { + mat4 viewProj; + vec4 center; + vec4 extents; + vec4 color; +} pc; + +layout(location = 0) in vec3 v_normal; +layout(location = 1) in vec3 v_world; +layout(location = 0) out vec4 o_color; + +void main() { + // World-space UVs: textures tile every 128 units regardless of box size. + vec3 n = abs(v_normal); + vec2 uv = (n.x > 0.5 ? v_world.yz : n.y > 0.5 ? v_world.xz : v_world.xy) / 128.0; + vec4 tex = sampleTex(uint(pc.center.w), uv); + o_color = vec4(pc.color.rgb * tex.rgb * sunLight(v_normal), 1.0); +} diff --git a/engine/shaders/box.vert b/engine/shaders/box.vert new file mode 100644 index 0000000..01f35dc --- /dev/null +++ b/engine/shaders/box.vert @@ -0,0 +1,26 @@ +#version 460 +// Axis-aligned boxes generated from the vertex index: 36 vertices, no vertex buffer. +#include "backend.glsl" +PUSH_BLOCK { + mat4 viewProj; + vec4 center; // xyz, w = texture index + vec4 extents; // xyz = half size + vec4 color; +} pc; + +layout(location = 0) out vec3 v_normal; +layout(location = 1) out vec3 v_world; + +const vec3 kNormals[6] = vec3[](vec3(1,0,0), vec3(-1,0,0), vec3(0,1,0), vec3(0,-1,0), vec3(0,0,1), vec3(0,0,-1)); +const vec2 kQuad[6] = vec2[](vec2(-1,-1), vec2(1,-1), vec2(1,1), vec2(-1,-1), vec2(1,1), vec2(-1,1)); + +void main() { + int face = VERTEX_ID / 6; + vec3 n = kNormals[face]; + vec3 t = abs(n.z) > 0.5 ? vec3(1,0,0) : vec3(0,0,1); + vec3 b = cross(n, t); + vec2 q = kQuad[VERTEX_ID % 6]; + v_world = pc.center.xyz + (n + t * q.x + b * q.y) * pc.extents.xyz; + v_normal = n; + gl_Position = pc.viewProj * vec4(v_world, 1.0); +} diff --git a/engine/shaders/cull.comp b/engine/shaders/cull.comp new file mode 100644 index 0000000..96112b5 --- /dev/null +++ b/engine/shaders/cull.comp @@ -0,0 +1,59 @@ +#version 460 +// GPU occlusion culling (r_gpuculling): one thread per opaque draw. Its world box, projected the +// way the occlusion grid's frame saw the world (last frame's view), is hidden if every grid texel +// under it holds something nearer than the box's nearest point. Writes the draw's indirect command +// (one instance or none) for the depth pre-pass and the scene pass, and counts the hidden ones. +#include "backend.glsl" +layout(local_size_x = 64) in; + +struct CullBox { vec4 lo; vec4 hi; uvec4 draw; }; // draw: index count, first index, test (1), - +layout(buffer_reference, std430) readonly buffer Boxes { CullBox b[]; }; +struct Cmd { uint indexCount, instanceCount, firstIndex; int vertexOffset; uint firstInstance; }; +layout(buffer_reference, std430) writeonly buffer Cmds { Cmd c[]; }; +layout(buffer_reference, std430) buffer Counter { uint hidden; }; + +PUSH_BLOCK { + mat4 viewProj; // the frame the grid was made from + Boxes boxes; + Cmds cmds; + Counter counter; + uint count; + uint hzb; // the pyramid's first level (kHzbLevels slots, each half the last) + uint occlusion; // 0: the camera moved too much since (draw everything in view) + uint pad; +} pc; + +void main() { + uint i = gl_GlobalInvocationID.x; + if (i >= pc.count) return; + CullBox b = pc.boxes.b[i]; + bool visible = true; + if (pc.occlusion != 0u && (b.draw.z & 1u) != 0u) { + vec3 mn = vec3(1e9), mx = vec3(-1e9); + bool behind = false; + for (int k = 0; k < 8; ++k) { + vec3 c = vec3((k & 1) != 0 ? b.hi.x : b.lo.x, (k & 2) != 0 ? b.hi.y : b.lo.y, (k & 4) != 0 ? b.hi.z : b.lo.z); + vec4 p = pc.viewProj * vec4(c, 1.0); + if (p.w <= 0.001) { behind = true; break; } // crosses the eye: can't tell, draw it + vec3 n = p.xyz / p.w; + mn = min(mn, n), mx = max(mx, n); + } + if (!behind) { + // The level where the box covers a few texels (each holds the farthest depth under + // it, so testing there never hides what's visible). + vec2 size0 = vec2(texSize(pc.hzb)); + vec2 extent = (mx.xy - mn.xy) * 0.5 * size0; + uint level = uint(clamp(ceil(log2(max(max(extent.x, extent.y), 1.0) / 3.0)), 0.0, 7.0)); + uint tex = pc.hzb + level; + ivec2 size = texSize(tex); + ivec2 a = clamp(ivec2(floor((mn.xy * 0.5 + 0.5) * vec2(size))), ivec2(0), size - 1); + ivec2 z = clamp(ivec2(floor((mx.xy * 0.5 + 0.5) * vec2(size))), ivec2(0), size - 1); + float far = 0.0; + for (int y = a.y; y <= z.y; ++y) + for (int x = a.x; x <= z.x; ++x) far = max(far, fetchTex(tex, ivec2(x, y)).r); + if (mn.z > far + 1e-5) visible = false; + } + } + pc.cmds.c[i] = Cmd(b.draw.x, visible ? 1u : 0u, b.draw.y, 0, 0u); + if (!visible) atomicAdd(pc.counter.hidden, 1u); +} diff --git a/engine/shaders/depth.frag b/engine/shaders/depth.frag new file mode 100644 index 0000000..35d614c --- /dev/null +++ b/engine/shaders/depth.frag @@ -0,0 +1,11 @@ +#version 460 +// Depth pre-pass (r_prepass): only the depth, with alpha-tested texels cut out like mesh.frag +// does, so the scene pass shades each pixel once (what's hidden behind isn't lit for nothing). +#include "backend.glsl" +#include "model.glsl" + +layout(location = 1) in vec2 v_uv; + +void main() { + if (sampleTex(pc.textures & 0xFFFFu, v_uv).a < 0.5) discard; +} diff --git a/engine/shaders/froxel.comp b/engine/shaders/froxel.comp new file mode 100644 index 0000000..3077bea --- /dev/null +++ b/engine/shaders/froxel.comp @@ -0,0 +1,2 @@ +#version 460 +#include "froxel_main.glsl" diff --git a/engine/shaders/froxel.glsl b/engine/shaders/froxel.glsl new file mode 100644 index 0000000..b07beb6 --- /dev/null +++ b/engine/shaders/froxel.glsl @@ -0,0 +1,30 @@ +// The fog grid ("froxels", as in Frostbite / id Tech): kFroxels cells over the screen and along +// the view, slices spaced exponentially from FRAME.froxel.y to .z. froxel.comp fills it; entry k +// of a column holds the light the fog adds (rgb) and how much of what's behind gets through (a) +// from the eye to the far side of slice k. +const uvec3 kFroxels = uvec3(160, 90, 64); +uint froxelIndex(uvec3 c) { return (c.z * kFroxels.y + c.y) * kFroxels.x + c.x; } +// Where slice boundary j lies (view depth); slice k spans boundaries k and k + 1. +float froxelBoundary(float j) { return FRAME.froxel.y * pow(FRAME.froxel.z / FRAME.froxel.y, j / float(kFroxels.z)); } +float froxelSlice(float depth) { return log(max(depth, 1e-3) / FRAME.froxel.y) / log(FRAME.froxel.z / FRAME.froxel.y) * float(kFroxels.z); } + +#ifndef KEEL_FROXEL_WRITER +vec4 froxelEntry(ivec2 xy, int z) { + uvec2 v = FRAME.froxels.f[froxelIndex(uvec3(clamp(xy, ivec2(0), ivec2(kFroxels.xy) - 1), clamp(z, 0, int(kFroxels.z) - 1)))]; + return vec4(unpackHalf2x16(v.x), unpackHalf2x16(v.y)); +} +vec4 froxelColumn(ivec2 xy, float c) { // c: entry index, fractional + if (c < 0.0) return mix(vec4(0.0, 0.0, 0.0, 1.0), froxelEntry(xy, 0), clamp(c + 1.0, 0.0, 1.0)); + int z = int(c); + return mix(froxelEntry(xy, z), froxelEntry(xy, z + 1), c >= float(kFroxels.z - 1) ? 0.0 : fract(c)); +} +// The fog between the eye and a point at uv on screen, depth along the view: (light, transmittance). +vec4 froxelFog(vec2 uv, float depth) { + vec2 g = uv * vec2(kFroxels.xy) - 0.5; + ivec2 b = ivec2(floor(g)); + vec2 f = g - vec2(b); + // Entry k ends at boundary k + 1; nearer than the first slice's far side fades in from nothing. + float c = max(froxelSlice(depth) - 1.0, -1.0); + return mix(mix(froxelColumn(b, c), froxelColumn(b + ivec2(1, 0), c), f.x), mix(froxelColumn(b + ivec2(0, 1), c), froxelColumn(b + ivec2(1, 1), c), f.x), f.y); +} +#endif diff --git a/engine/shaders/froxel_main.glsl b/engine/shaders/froxel_main.glsl new file mode 100644 index 0000000..292948e --- /dev/null +++ b/engine/shaders/froxel_main.glsl @@ -0,0 +1,218 @@ +// The body of froxel.comp and froxel_rt.comp (the same, plus rays for the sky above each cell). +// The fog grid (froxel.glsl), in two dispatches: +// mode 0, one thread per cell: the fog's density there (height fog, plus the air when +// r_volumetric's on) and the light it scatters towards the eye (the sky's, the sun through its +// shadow, the lamps of its cluster), at a point jittered along the slice each frame and blended +// with last frame's cell at the same place in the world, so it's smooth and doesn't crawl. +// mode 1, one thread per column: walked front to back, adding up light and transmittance. +#include "backend.glsl" +#define KEEL_FRAME_ONLY +#include "model.glsl" +#include "sky.glsl" +layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; +layout(buffer_reference, std430) buffer Cells4 { vec4 c[]; }; // per cell: scattered light (per unit), extinction +layout(buffer_reference, std430) writeonly buffer Out { uvec2 f[]; }; +layout(buffer_reference, std430) readonly buffer SmokeCells { uint v[]; }; // two cells a word +layout(buffer_reference, std430) writeonly buffer SmokeSky { uvec2 f[]; }; // a column's smoke alone +PUSH_BLOCK { + FrameData frame; + Cells4 cur, prev; + Out integrated; + vec4 proj; // tan(half fov x), tan(half fov y) + vec4 prevPos; // last frame's eye; w 1 = there's a last frame to blend with + vec4 prevFwd; // and its view direction; w this frame's jitter along the slice (0..1) + uint mode; + uint frameIndex; + uvec2 tlas; // KEEL_FOG_RAYS: the scene's acceleration structure + SmokeCells smoke; // the smoke grid (render/backend.h kSmokeDims): density | shade << 8 a cell + SmokeSky smokeSky; // integrate: each column's smoke alone, end to end (smoke_sky.frag lays it over the sky) + vec4 smokeOrigin; // its world corner; w a clock (seconds) for the billows, < 0: no smoke +} pc; +#define FRAME pc.frame +#define KEEL_FROXEL_WRITER +#include "froxel.glsl" +#include "airlight.glsl" + +// ── Smoke (shared/smoke.h): the grid's cells, blended, roughened by drifting billows ── +const ivec3 kSmokeDims = ivec3(160, 160, 48); +const float kSmokeCell = 16.0; +vec2 smokeCell(ivec3 c) { + if (any(lessThan(c, ivec3(0))) || any(greaterThanEqual(c, kSmokeDims))) return vec2(0.0); + uint i = uint((c.z * kSmokeDims.y + c.y) * kSmokeDims.x + c.x); + uint h = (pc.smoke.v[i >> 1] >> ((i & 1u) * 16u)) & 0xFFFFu; + return vec2(float(h & 0xFFu), float(h >> 8)) / 255.0; +} +// x density, y shade (both blended between the 8 cells round w). +vec2 smokeAt(vec3 w) { + vec3 g = (w - pc.smokeOrigin.xyz) / kSmokeCell - 0.5; + ivec3 b = ivec3(floor(g)); + vec3 f = g - vec3(b); + float d = 0.0, s = 0.0; + for (int k = 0; k < 8; ++k) { + ivec3 o = ivec3(k & 1, (k >> 1) & 1, k >> 2); + vec3 tri = mix(1.0 - f, f, vec3(o)); + vec2 c = smokeCell(b + o); + float wgt = tri.x * tri.y * tri.z; + d += c.x * wgt; + s += c.y * c.x * wgt; + } + return vec2(d, d > 1e-4 ? s / d : 0.5); +} +float hash3(vec3 p) { + p = fract(p * 0.3183099 + 0.1) * 17.0; + return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); +} +float valueNoise(vec3 x) { + vec3 i = floor(x), f = fract(x); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(mix(hash3(i), hash3(i + vec3(1, 0, 0)), f.x), mix(hash3(i + vec3(0, 1, 0)), hash3(i + vec3(1, 1, 0)), f.x), f.y), + mix(mix(hash3(i + vec3(0, 0, 1)), hash3(i + vec3(1, 0, 1)), f.x), mix(hash3(i + vec3(0, 1, 1)), hash3(i + vec3(1, 1, 1)), f.x), f.y), f.z); +} +// The density there after the billows: thick inside, torn and drifting at the edges. +float smokeDensity(vec3 w, float d) { + if (d <= 0.0) return 0.0; + float t = pc.smokeOrigin.w; + vec3 q = w * 0.03 + vec3(t * 0.04, t * 0.03, -t * 0.12); + float n = 0.5 * valueNoise(q) + 0.3 * valueNoise(q * 2.3 + vec3(t * 0.07)) + 0.2 * valueNoise(q * 5.1 - vec3(t * 0.1)); + // Thin smoke (the rim) is torn most; the thick middle only darkens and lightens a little. + return clamp(d * 1.6 - (1.0 - n) * mix(1.1, 0.5, d), 0.0, 1.0) * mix(0.75, 1.15, n); +} + +vec3 viewRay(vec3 fwd, vec2 uv) { // not normalized: 1 along fwd + vec3 right = normalize(cross(fwd, vec3(0.0, 0.0, 1.0))), up = cross(right, fwd); + vec2 s = (uv * 2.0 - 1.0) * pc.proj.xy; + return fwd + right * s.x - up * s.y; +} + +// Last frame's cell (blended) at a world point; w < 0 when it was off that grid. +vec4 history(vec3 w) { + vec3 fwd = pc.prevFwd.xyz, right = normalize(cross(fwd, vec3(0.0, 0.0, 1.0))), up = cross(right, fwd); + vec3 v = w - pc.prevPos.xyz; + float depth = dot(v, fwd); + if (depth < FRAME.froxel.y) return vec4(-1.0); + vec2 uv = (vec2(dot(v, right), -dot(v, up)) / depth / pc.proj.xy) * 0.5 + 0.5; + vec3 g = vec3(uv * vec2(kFroxels.xy), froxelSlice(depth)) - 0.5; + if (any(lessThan(g, vec3(0.0))) || any(greaterThan(g, vec3(kFroxels) - 1.0))) return vec4(-1.0); + ivec3 b = ivec3(floor(g)); + vec3 f = g - vec3(b); + vec4 r = vec4(0.0); + for (int k = 0; k < 8; ++k) { + ivec3 o = ivec3(k & 1, (k >> 1) & 1, k >> 2); + float wgt = (o.x != 0 ? f.x : 1.0 - f.x) * (o.y != 0 ? f.y : 1.0 - f.y) * (o.z != 0 ? f.z : 1.0 - f.z); + r += pc.prev.c[froxelIndex(uvec3(min(b + o, ivec3(kFroxels) - 1)))] * wgt; + } + return r; +} + +void inject(uvec3 c) { + vec2 uv = (vec2(c.xy) + 0.5) / vec2(kFroxels.xy); + vec3 ray = viewRay(FRAME.camFwd.xyz, uv), rd = normalize(ray); + vec3 w = FRAME.camPos.xyz + ray * froxelBoundary(float(c.z) + pc.prevFwd.w); + float fog = 0.0; + if (FRAME.sky.x > 0.5 && FRAME.fog.x > 0.0) fog = FRAME.fog.x * exp(-FRAME.fog.y * (w.z - FRAME.fog.w)); + float air = FRAME.volumetric.x > 0.0 ? FRAME.volumetric.z : 0.0; + vec2 smoke = pc.smokeOrigin.w >= 0.0 ? smokeAt(w) : vec2(0.0); + float smokeExt = smokeDensity(w, smoke.x) * 0.035; // (thick smoke: ~30 units hide what's behind) + vec3 light = vec3(0.0); + if (fog + air + smokeExt > 0.0) { + const bool sunOn = FRAME.sunColor.w > 0.5; + float lit = sunOn ? sunlitAt(w) : 0.0; + float mu = dot(rd, FRAME.sunDir.xyz); + vec3 lamps = lightsAt(w, rd, uv); + // The fog: lit by the sky (blurred, as the old height fog was) and, where the sun reaches + // it, glowing towards the sun; the lamps light it too. + vec3 sky = FRAME.sky.x > 0.5 ? skyRadiance(uint(FRAME.sky.x), FRAME.sky.y, FRAME.sky.w, normalize(vec3(rd.xy, max(rd.z, 0.05))), FRAME.sky.z) * 0.9 : vec3(0.0); + vec3 smokeSky = sky; // (the smoke's own: steady, no per-frame noise to average away) +#ifdef KEEL_FOG_RAYS + // How much sky this point sees: one ray a frame into the sky above it, in a different + // direction each frame (the blend with last frame's cells averages them). Under a roof + // the fog gets next to no sky light, so rooms stay clear instead of filling with grey. + uint h = (c.x * 73856093u) ^ (c.y * 19349663u) ^ (c.z * 83492791u) ^ (pc.frameIndex * 2654435761u); + h = h * 747796405u + 2891336453u; + float r1 = float(h & 0xFFFFu) / 65535.0, r2 = float(h >> 16) / 65535.0; + vec3 up = normalize(vec3(cos(r1 * 6.2831853), sin(r1 * 6.2831853), 0.0) * sqrt(r2) * 1.2 + vec3(0.0, 0.0, 1.0)); + rayQueryEXT q; + rayQueryInitializeEXT(q, accelerationStructureEXT(pc.tlas), gl_RayFlagsTerminateOnFirstHitEXT | gl_RayFlagsOpaqueEXT, 0xFF, w, 1.0, up, 6000.0); + while (rayQueryProceedEXT(q)) {} + float open = rayQueryGetIntersectionTypeEXT(q, true) == gl_RayQueryCommittedIntersectionNoneEXT ? 1.0 : 0.0; + sky *= mix(0.06, 1.0, open); + if (smokeExt > 0.0) { // under a roof or not: one ray straight up + rayQueryEXT q2; + rayQueryInitializeEXT(q2, accelerationStructureEXT(pc.tlas), gl_RayFlagsTerminateOnFirstHitEXT | gl_RayFlagsOpaqueEXT, 0xFF, w, 1.0, vec3(0.0, 0.0, 1.0), 6000.0); + while (rayQueryProceedEXT(q2)) {} + smokeSky *= rayQueryGetIntersectionTypeEXT(q2, true) == gl_RayQueryCommittedIntersectionNoneEXT ? 1.0 : 0.3; + } +#else + // Where the sun can't reach (under a roof, mostly) the sky's light is dimmer too. + if (sunOn) sky *= mix(0.3, 1.0, lit); + smokeSky = sky; +#endif + vec3 fogLight = sky + (sunOn ? FRAME.sunColor.rgb * lit * pow(max(mu, 0.0), 8.0) * 0.25 : vec3(0.0)) + lamps * 12.0; + // The air (r_volumetric): shafts of sun and haloes round lamps; it adds light only. + vec3 airLight = (sunOn && FRAME.volumetric.w > 0.5 ? FRAME.sunColor.rgb * lit * phaseHG(mu, 0.6) : vec3(0.0)) + lamps * 7.0; + light = fog * fogLight + air * airLight * FRAME.volumetric.x; + if (smokeExt > 0.0) { + // Smoke: the sky's light all round, the sun through its shadows and through the smoke + // between it and the sun (a few steps that way), the lamps; tinted by its shade. + float toward = sunOn ? smokeAt(w + FRAME.sunDir.xyz * 24.0).x + smokeAt(w + FRAME.sunDir.xyz * 64.0).x + smokeAt(w + FRAME.sunDir.xyz * 128.0).x : 0.0; + vec3 sunIn = sunOn ? FRAME.sunColor.rgb * lit * exp(-toward * 1.4) * 0.5 : vec3(0.0); + // The smoke above shades what's under it (the sky's light comes mostly from above). + float above = smokeAt(w + vec3(0.0, 0.0, 32.0)).x + smokeAt(w + vec3(0.0, 0.0, 80.0)).x; + light += smokeExt * smoke.y * (smokeSky * mix(1.0, 0.35, clamp(above * 0.5, 0.0, 1.0)) + sunIn + lamps * 4.0); + } + } + vec4 now = vec4(light, fog + smokeExt); + uint i = froxelIndex(c); + if (pc.prevPos.w > 0.5) { + vec4 h = history(w); + if (h.w >= 0.0) now = mix(h, now, 0.12); + } + pc.cur.c[i] = now; +} + +void integrate(uvec2 xy) { + vec2 uv = (vec2(xy) + 0.5) / vec2(kFroxels.xy); + float rayScale = length(viewRay(FRAME.camFwd.xyz, uv)); // ray length per unit of depth + vec3 ray = viewRay(FRAME.camFwd.xyz, uv); + vec3 light = vec3(0.0); + // The fog never hides more than its max (FRAME.fog.z; 0 or 1: no cap): once the fog alone has + // hidden that much, the cells further on keep only their smoke (and its share of the light). + float through = 1.0, fogThrough = 1.0, keep = 1.0 - FRAME.fog.z; + vec3 smokeLight = vec3(0.0); + float smokeThrough = 1.0; + bool capped = FRAME.fog.z > 0.0 && FRAME.fog.z < 1.0; + float near = 0.0; + for (uint z = 0u; z < kFroxels.z; ++z) { + float far = froxelBoundary(float(z + 1u)), len = (far - near) * rayScale; + vec3 w = FRAME.camPos.xyz + ray * (near + far) * 0.5; + near = far; + vec4 cell = pc.cur.c[froxelIndex(uvec3(xy, z))]; + // The smoke here, sampled again (exact: it doesn't move with the slices' jitter as the fog + // would); the rest of the cell's extinction is the fog. + float smokeHere = pc.smokeOrigin.w >= 0.0 ? min(smokeDensity(w, smokeAt(w).x) * 0.035, cell.w) : 0.0; + float fog = max(cell.w - smokeHere, 0.0); + if (capped && fogThrough <= keep && cell.w > 1e-9) { + cell.rgb *= (cell.w - fog) / cell.w; + cell.w -= fog; + fog = 0.0; + } + fogThrough *= exp(-fog * len); + if (smokeHere > 1e-9) { // the smoke's share of the cell + float se = smokeHere, st = exp(-se * len); + smokeLight += smokeThrough * cell.rgb * (se / cell.w) * (1.0 - st) / se; + smokeThrough *= st; + } + float t = exp(-cell.w * len); + light += through * (cell.w > 1e-9 ? cell.rgb * (1.0 - t) / cell.w : cell.rgb * len); + through *= t; + pc.integrated.f[froxelIndex(uvec3(xy, z))] = uvec2(packHalf2x16(light.rg), packHalf2x16(vec2(light.b, through))); + } + if (pc.smokeOrigin.w >= 0.0) pc.smokeSky.f[xy.y * kFroxels.x + xy.x] = uvec2(packHalf2x16(smokeLight.rg), packHalf2x16(vec2(smokeLight.b, smokeThrough))); +} + +void main() { + uvec3 id = gl_GlobalInvocationID; + if (any(greaterThanEqual(id.xy, kFroxels.xy))) return; + if (pc.mode == 0u) inject(id); + else integrate(id.xy); +} diff --git a/engine/shaders/froxel_rt.comp b/engine/shaders/froxel_rt.comp new file mode 100644 index 0000000..bcebc79 --- /dev/null +++ b/engine/shaders/froxel_rt.comp @@ -0,0 +1,5 @@ +#version 460 +// The fog grid with ray queries: each cell sees whether it has sky above it (froxel_main.glsl). +#extension GL_EXT_ray_query : require +#define KEEL_FOG_RAYS +#include "froxel_main.glsl" diff --git a/engine/shaders/fullscreen.vert b/engine/shaders/fullscreen.vert new file mode 100644 index 0000000..12bdd9e --- /dev/null +++ b/engine/shaders/fullscreen.vert @@ -0,0 +1,11 @@ +#version 460 +#include "backend.glsl" +layout(location = 0) out vec2 v_uv; +void main() { + v_uv = vec2((VERTEX_ID << 1) & 2, VERTEX_ID & 2); + gl_Position = vec4(v_uv * 2.0 - 1.0, 0.0, 1.0); +#ifdef KEEL_GL + // The window shows row 0 at the bottom in OpenGL; our images keep the view's top in row 0 (as in Vulkan). + v_uv.y = 1.0 - v_uv.y; +#endif +} diff --git a/engine/shaders/gi.comp b/engine/shaders/gi.comp new file mode 100644 index 0000000..e528a07 --- /dev/null +++ b/engine/shaders/gi.comp @@ -0,0 +1,143 @@ +#version 460 +// Real-time GI (gi.glsl): one workgroup per probe, one hardware ray per thread (ray queries +// against this frame's scene). Where a ray lands: that surface's colour, lit by the dynamic +// lights that reach it (a shadow ray each) and by last frame's probes there (so light bounces +// more than once); behind a surface (the probe's stuck in a wall) it counts against the probe. +// Then the probe's harmonics and distances, blended with what it had. +#extension GL_EXT_ray_query : require +#include "backend.glsl" +#define KEEL_FRAME_ONLY +#include "model.glsl" +layout(local_size_x = 32) in; +layout(buffer_reference, std430) readonly buffer GiVertices { Vertex v[]; }; +layout(buffer_reference, std430) readonly buffer GiIndices { uint i[]; }; +struct RayInst { GiVertices vertices; GiIndices indices; uint firstIndex, textures, tint, pad; }; +layout(buffer_reference, std430) readonly buffer RayInsts { RayInst r[]; }; +layout(buffer_reference, std430) writeonly buffer GiProbesOut { GiProbe p[]; }; +PUSH_BLOCK { + FrameData frame; + GiProbesOut cur; + GiProbes prev; + RayInsts insts; + uvec2 tlas; // the scene's acceleration structure + uint fresh; // 1: start over (nothing to blend with) + float hysteresis; // how much of the old light stays each frame + vec4 rotation; // this frame's turn of the ray pattern (quaternion) +} pc; +#define FRAME pc.frame +#define GI_PROBES pc.prev +#include "gi.glsl" + +const uint kRays = 32u; +shared vec3 s_light[kRays], s_dir[kRays]; +shared float s_dist[kRays]; +shared uint s_back[kRays]; + +vec3 rotate(vec4 q, vec3 v) { return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v); } +vec3 fibonacci(uint i, uint n) { + float z = 1.0 - (2.0 * float(i) + 1.0) / float(n), r = sqrt(max(1.0 - z * z, 0.0)); + float phi = float(i) * 2.39996323; + return vec3(cos(phi) * r, sin(phi) * r, z); +} +bool blocked(vec3 from, vec3 dir, float len) { + rayQueryEXT q; + rayQueryInitializeEXT(q, accelerationStructureEXT(pc.tlas), gl_RayFlagsTerminateOnFirstHitEXT | gl_RayFlagsOpaqueEXT, 0xFF, from, 0.01, dir, len); + while (rayQueryProceedEXT(q)) {} + return rayQueryGetIntersectionTypeEXT(q, true) != gl_RayQueryCommittedIntersectionNoneEXT; +} + +void main() { + uint probe = gl_WorkGroupID.x, t = gl_LocalInvocationID.x; + ivec3 slot = ivec3(probe % uint(kGiDims.x), (probe / uint(kGiDims.x)) % uint(kGiDims.y), probe / uint(kGiDims.x * kGiDims.y)); + ivec3 origin = ivec3(FRAME.giOrigin.xyz); + ivec3 cell = origin + giWrap(slot - origin); // the world cell this slot holds now + vec3 pos = (vec3(cell) + 0.5) * FRAME.gi.y; // between floors and walls (on the grid lines maps are built on) + GiProbe old = pc.prev.p[probe]; + bool fresh = pc.fresh != 0u || old.tag.w < 0.5 || ivec3(old.tag.xyz) != cell; + + vec3 dir = normalize(rotate(pc.rotation, fibonacci(t, kRays))); + const float kFar = FRAME.gi.y * 8.0; + rayQueryEXT q; + rayQueryInitializeEXT(q, accelerationStructureEXT(pc.tlas), gl_RayFlagsOpaqueEXT, 0xFF, pos, 0.0, dir, kFar); + while (rayQueryProceedEXT(q)) {} + vec3 light = vec3(0.0); + float dist = kFar; + uint back = 0u; + if (rayQueryGetIntersectionTypeEXT(q, true) == gl_RayQueryCommittedIntersectionTriangleEXT) { + dist = rayQueryGetIntersectionTEXT(q, true); + RayInst ri = pc.insts.r[rayQueryGetIntersectionInstanceCustomIndexEXT(q, true)]; + uint first = ri.firstIndex + uint(rayQueryGetIntersectionPrimitiveIndexEXT(q, true)) * 3u; + Vertex a = ri.vertices.v[ri.indices.i[first]], b = ri.vertices.v[ri.indices.i[first + 1u]], c = ri.vertices.v[ri.indices.i[first + 2u]]; + vec2 bc = rayQueryGetIntersectionBarycentricsEXT(q, true); + vec3 w3 = vec3(1.0 - bc.x - bc.y, bc.x, bc.y); + mat4x3 toWorld = rayQueryGetIntersectionObjectToWorldEXT(q, true); + vec3 n = normalize(mat3(toWorld) * (vec3(a.nx, a.ny, a.nz) * w3.x + vec3(b.nx, b.ny, b.nz) * w3.y + vec3(c.nx, c.ny, c.nz) * w3.z)); + if (dot(n, dir) > 0.0) { // its back: inside something (a wall's far side, a closed box) + back = 1u; + dist *= 0.2; + } else { + vec2 uv = vec2(a.u, a.v) * w3.x + vec2(b.u, b.v) * w3.y + vec2(c.u, c.v) * w3.z; + vec3 albedo = sampleTexLod(ri.textures & 0xFFFFu, uv, 4.0).rgb * unpackUnorm4x8(ri.tint).rgb; + vec3 hit = pos + dir * dist + n * 0.5; + vec3 direct = vec3(0.0); + uint count = min(uint(FRAME.camPos.w), 32u); + for (uint k = 0u; k < count; ++k) { + Light l = LIGHT(k); + vec3 toLight = l.posRadius.xyz - hit; + float d = length(toLight); + if (d >= l.posRadius.w) continue; + vec3 L = toLight / max(d, 0.001); + float ndl = dot(n, L); + if (ndl <= 0.0) continue; + float fall = 1.0 - d / l.posRadius.w, atten = fall * fall; + if (l.spot.w > -1.5) atten *= spotShape(l, dot(-L, l.spot.xyz)); + // (Stopping short of the light: lamps sit inside their fittings.) + if (atten <= 1e-3 || blocked(hit, L, max(d - 12.0, 0.0))) continue; + direct += l.color.rgb * atten * ndl; + } + // More bounces: what last frame's probes say reaches that spot. + direct += giIrradiance(hit, n, -dir); + light = albedo * direct; + } + } + s_light[t] = light; + s_dir[t] = dir; + s_dist[t] = dist; + s_back[t] = back; + barrier(); + if (t != 0u) return; + + GiProbe p; + for (int i = 0; i < 4; ++i) p.sh[i] = vec4(0.0); + vec4 mean0 = vec4(0.0), mean1 = vec4(0.0), w0 = vec4(0.0), w1 = vec4(0.0); // +x -x +y -y, +z -z (and their squares) + vec4 sq0 = vec4(0.0), sq1 = vec4(0.0); + uint backs = 0u; + const float weight = 12.566371 / float(kRays); + for (uint i = 0u; i < kRays; ++i) { + vec3 d = s_dir[i], L = s_light[i] * weight; + p.sh[0].rgb += L * 0.282095; + p.sh[1].rgb += L * 0.488603 * d.y; + p.sh[2].rgb += L * 0.488603 * d.z; + p.sh[3].rgb += L * 0.488603 * d.x; + vec4 a = vec4(max(d.x, 0.0), max(-d.x, 0.0), max(d.y, 0.0), max(-d.y, 0.0)), b = vec4(max(d.z, 0.0), max(-d.z, 0.0), 0.0, 0.0); + a = pow(a, vec4(4.0)), b = pow(b, vec4(4.0)); + float r = s_dist[i]; + mean0 += a * r, sq0 += a * r * r, w0 += a; + mean1 += b * r, sq1 += b * r * r, w1 += b; + backs += s_back[i]; + } + mean0 /= max(w0, vec4(1e-4)), sq0 /= max(w0, vec4(1e-4)); + mean1 /= max(w1, vec4(1e-4)), sq1 /= max(w1, vec4(1e-4)); + p.dist[0] = mean0; // +x -x +y -y + p.dist[1] = vec4(mean1.xy, sq0.xy); // +z -z, then squares +x -x + p.dist[2] = vec4(sq0.zw, sq1.xy); // squares +y -y +z -z + // Stuck in a wall (many rays see backs): don't trust it. + p.sh[0].w = float(backs) > float(kRays) * 0.25 ? 0.0 : 1.0; + p.tag = vec4(vec3(cell), 1.0); + if (!fresh) { + float h = pc.hysteresis; + for (int i = 0; i < 4; ++i) p.sh[i].rgb = mix(p.sh[i].rgb, old.sh[i].rgb, h); + for (int i = 0; i < 3; ++i) p.dist[i] = mix(p.dist[i], old.dist[i], 0.97); // shapes change slowly + } + pc.cur.p[probe] = p; +} diff --git a/engine/shaders/gi.glsl b/engine/shaders/gi.glsl new file mode 100644 index 0000000..f59b572 --- /dev/null +++ b/engine/shaders/gi.glsl @@ -0,0 +1,56 @@ +// Real-time GI probes (gi.comp fills them, with ray queries): a grid of kGiDims probes, +// FRAME.gi.y apart, that follows the eye; a probe's slot is its world cell wrapped into the grid, +// so moving keeps what's already there. They hold the light that dynamic lights (flashlight, +// fire, explosions, lamps) bounce off what's around; the lightmaps already bounce the rest. +// GI_PROBES: the probes to read (#define before including). +const ivec3 kGiDims = ivec3(24, 24, 12); +// a mod kGiDims, also below zero (GLSL's % isn't defined there) +ivec3 giWrap(ivec3 a) { return a - kGiDims * ivec3(floor(vec3(a) / vec3(kGiDims))); } +uint giSlot(ivec3 cell) { + ivec3 s = giWrap(cell); + return uint((s.z * kGiDims.y + s.y) * kGiDims.x + s.x); +} +// Irradiance / π from L1 spherical harmonics of radiance, facing n. +vec3 giShIrradiance(GiProbe p, vec3 n) { + return max(0.282095 * p.sh[0].rgb + 0.325735 * (p.sh[1].rgb * n.y + p.sh[2].rgb * n.z + p.sh[3].rgb * n.x), vec3(0.0)); +} +// The light bounced onto a surface at world (normal n, towards the eye v): the 8 probes round it, +// weighted by nearness, whether they're in front of the surface, and whether they can see it +// (what's between, by the distances each one keeps: Chebyshev, as in DDGI). +vec3 giIrradiance(vec3 world, vec3 n, vec3 v) { + float s = FRAME.gi.y; + ivec3 origin = ivec3(FRAME.giOrigin.xyz); + vec3 p = world + (n * 0.2 + v * 0.1) * s; + vec3 g = p / s - 0.5; // probe c sits at (c + 0.5) * s + ivec3 base = ivec3(floor(g)); + vec3 f = g - vec3(base); + vec3 sum = vec3(0.0); + float total = 0.0; + for (int k = 0; k < 8; ++k) { + ivec3 o = ivec3(k & 1, (k >> 1) & 1, k >> 2), c = base + o; + if (any(lessThan(c, origin)) || any(greaterThanEqual(c, origin + kGiDims))) continue; + GiProbe pr = GI_PROBES.p[giSlot(c)]; + if (pr.tag.w < 0.5 || ivec3(pr.tag.xyz) != c) continue; + vec3 tri = mix(1.0 - f, f, vec3(o)); + float w = tri.x * tri.y * tri.z * pr.sh[0].w; + vec3 at = (vec3(c) + 0.5) * s; + vec3 toProbe = normalize(at - world); + float wrap = (dot(toProbe, n) + 1.0) * 0.5; + w *= wrap * wrap + 0.2; + vec3 d = p - at; + float r = length(d); + d /= max(r, 1e-4); + vec3 d2 = d * d; + vec2 m = vec2(d.x > 0.0 ? pr.dist[0].x : pr.dist[0].y, d.x > 0.0 ? pr.dist[1].z : pr.dist[1].w) * d2.x + + vec2(d.y > 0.0 ? pr.dist[0].z : pr.dist[0].w, d.y > 0.0 ? pr.dist[2].x : pr.dist[2].y) * d2.y + + vec2(d.z > 0.0 ? pr.dist[1].x : pr.dist[1].y, d.z > 0.0 ? pr.dist[2].z : pr.dist[2].w) * d2.z; + if (r > m.x) { + float variance = abs(m.y - m.x * m.x), e = r - m.x; + float cheb = variance / (variance + e * e); + w *= max(cheb * cheb * cheb, 0.02); + } + sum += giShIrradiance(pr, n) * w; + total += w; + } + return total > 1e-4 ? sum / total : vec3(0.0); +} diff --git a/engine/shaders/hzb.frag b/engine/shaders/hzb.frag new file mode 100644 index 0000000..a31c4de --- /dev/null +++ b/engine/shaders/hzb.frag @@ -0,0 +1,24 @@ +#version 460 +// The hierarchical Z-buffer's base: the scene's depth shrunk to a small grid, each texel the +// farthest depth under it. The CPU reads it back and builds the rest of the pyramid (see +// Renderer::occluded). Rows match the depth buffer's on both backends. +#define KEEL_POST // screen-space: render targets, sampled clamped +#include "backend.glsl" +PUSH_BLOCK { + uint depth; // texture slot of the scene's depth + uint srcW, srcH; // its size + uint dstW, dstH; // this grid's +} pc; + +layout(location = 0) out vec4 o_depth; + +void main() { + ivec2 o = ivec2(gl_FragCoord.xy); + vec2 scale = vec2(pc.srcW, pc.srcH) / vec2(pc.dstW, pc.dstH); + ivec2 lo = ivec2(floor(vec2(o) * scale)); + ivec2 hi = min(ivec2(ceil(vec2(o + 1) * scale)), ivec2(pc.srcW, pc.srcH)); + float far = 0.0; + for (int y = lo.y; y < hi.y; ++y) + for (int x = lo.x; x < hi.x; ++x) far = max(far, fetchTex(pc.depth, ivec2(x, y)).r); + o_depth = vec4(far); +} diff --git a/engine/shaders/lights.glsl b/engine/shaders/lights.glsl new file mode 100644 index 0000000..d1573ce --- /dev/null +++ b/engine/shaders/lights.glsl @@ -0,0 +1,99 @@ +// Clustered dynamic lights for mesh.frag. Grid constants match render/clusters.h. +const uvec3 kClusters = uvec3(16, 9, 24); +const float kClusterNear = 8.0, kClusterFar = 16384.0; + +uint clusterIndex(vec3 world) { + float depth = dot(world - FRAME.camPos.xyz, FRAME.camFwd.xyz); + uint z = depth <= kClusterNear ? 0u : min(uint(log(depth / kClusterNear) / log(kClusterFar / kClusterNear) * float(kClusters.z)), kClusters.z - 1u); + uvec2 tile = min(uvec2(gl_FragCoord.xy / FRAME.screen.xy * vec2(kClusters.xy)), kClusters.xy - 1u); + return (z * kClusters.y + tile.y) * kClusters.x + tile.x; +} + +// 1 = lit. 3×3 bilinear PCF taps inside the light's tile of the shadow atlas. +float atlasShadow(int view, vec3 world, vec3 n, float dist) { + ShadowView sv = SHADOW_VIEW(view); + vec4 c = sv.viewProj * vec4(world + n * (0.75 + dist * 0.006), 1.0); + if (c.w <= 0.0) return 1.0; + c.xyz /= c.w; + if (any(greaterThan(abs(c.xy), vec2(1.0))) || c.z > 1.0) return 1.0; + float texel = 1.0 / FRAME.screen.w; + vec2 lo = sv.rect.xy + texel, hi = sv.rect.xy + sv.rect.zw - texel; + vec2 at = sv.rect.xy + (c.xy * 0.5 + 0.5) * sv.rect.zw; + uint atlas = uint(FRAME.screen.z); + float lit = 0.0; + for (int y = -1; y <= 1; ++y) + for (int x = -1; x <= 1; ++x) lit += shadowTap(atlas, at + vec2(x, y) * texel, lo, hi, c.z - 0.00002); + return lit / 9.0; +} + +// GGX specular: height-correlated Smith visibility, Schlick Fresnel (F0: g_f0). +// Times π, because our light values are "what a white wall shows" (diffuse = albedo × light). +// Multiply by n·l and the light. rough: perceptual roughness. +// Reflectance at normal incidence for this surface (mesh.frag sets it: 0.04 for dielectrics, +// the albedo for metals). +vec3 g_f0 = vec3(0.04); + +vec3 specGGX(vec3 n, vec3 l, vec3 v, float rough) { + vec3 h = normalize(l + v); + float a = max(rough * rough, 0.002), a2 = a * a; + float ndl = max(dot(n, l), 1e-4), ndv = max(dot(n, v), 1e-4), ndh = max(dot(n, h), 0.0), vdh = max(dot(v, h), 0.0); + float q = ndh * ndh * (a2 - 1.0) + 1.0; + float d = a2 / (3.14159265 * q * q); + float vis = 0.5 / (ndl * sqrt(ndv * ndv * (1.0 - a2) + a2) + ndv * sqrt(ndl * ndl * (1.0 - a2) + a2)); + vec3 f = g_f0 + (1.0 - g_f0) * pow(1.0 - vdh, 5.0); + return min(3.14159265 * d * vis * f, vec3(64.0)); +} + +// Light reaching the surface from every dynamic light in its cluster: diffuse (returned) and +// the highlights (spec), sharper and stronger the glossier the surface. +vec3 dynamicLights(vec3 world, vec3 n, float rough, vec3 v, out uint count, out vec3 spec) { + count = 0u; + spec = vec3(0.0); + if (FRAME.camPos.w < 0.5) return vec3(0.0); + // A mirror's pass sees from somewhere else than the clusters were made for: every light. + bool every = FRAME.mirror.y > 0.5; + uvec2 cell = every ? uvec2(0u, min(uint(FRAME.camPos.w), 64u)) : CELL(clusterIndex(world)); + count = cell.y; + vec3 total = vec3(0.0); + for (uint k = 0u; k < cell.y; ++k) { + Light l = LIGHT(every ? k : LIGHT_INDEX(cell.x + k)); + vec3 toLight = l.posRadius.xyz - world; + float dist = length(toLight); + if (dist >= l.posRadius.w) continue; + vec3 L = toLight / max(dist, 0.001); + float ndl = dot(n, L); + if (ndl <= 0.0) continue; + float a = 1.0 - dist / l.posRadius.w; + float atten = a * a; + if (l.spot.w > -1.5) atten *= spotShape(l, dot(-L, l.spot.xyz)); + if (atten <= 0.0) continue; + if (l.color.w >= 0.0) { + int view = int(l.color.w); + if (l.spot.w <= -1.5) { // point light: one atlas tile per cube face, +X -X +Y -Y +Z -Z + vec3 d = -toLight, ad = abs(d); + view += ad.x >= ad.y && ad.x >= ad.z ? (d.x > 0.0 ? 0 : 1) : ad.y >= ad.z ? (d.y > 0.0 ? 2 : 3) : (d.z > 0.0 ? 4 : 5); + } + atten *= atlasShadow(view, world, n, dist); + } + total += l.color.rgb * atten * ndl; + // Area light (a sphere of radius extra.y): the highlight comes from the point on it + // nearest the reflected ray, and widens to match (Karis 2013, "representative point"). + vec3 Ls = L; + float norm = 1.0; + if (l.extra.y > 0.0) { + vec3 r = reflect(-v, n); + vec3 toRay = dot(toLight, r) * r - toLight; + vec3 closest = toLight + toRay * clamp(l.extra.y / max(length(toRay), 1e-3), 0.0, 1.0); + Ls = normalize(closest); + float a = max(rough * rough, 0.002), a2 = clamp(a + l.extra.y / (2.0 * dist), 0.0, 1.0); + norm = (a / a2) * (a / a2); + } + spec += l.color.rgb * atten * ndl * specGGX(n, Ls, v, rough) * norm; + } + return total; +} + +vec3 heatmap(uint count) { + float t = clamp(float(count) / 16.0, 0.0, 1.0); + return count == 0u ? vec3(0.0, 0.0, 0.15) : mix(vec3(0.0, 0.4, 1.0), vec3(1.0, 0.1, 0.0), t); +} diff --git a/engine/shaders/mesh.frag b/engine/shaders/mesh.frag new file mode 100644 index 0000000..f8e4010 --- /dev/null +++ b/engine/shaders/mesh.frag @@ -0,0 +1,376 @@ +#version 460 +#include "backend.glsl" +#include "model.glsl" +#include "lights.glsl" +#include "sky.glsl" + +layout(location = 0) in vec3 v_normal; +layout(location = 1) in vec2 v_uv; +layout(location = 2) in vec2 v_lightmapUv; +layout(location = 3) in vec3 v_world; +layout(location = 0) out vec4 o_color; +layout(location = 1) out vec4 o_gbuf; // view normal (octahedral), roughness, reflection weight: for ssr.frag + +vec2 octEncode(vec3 n) { + n /= abs(n.x) + abs(n.y) + abs(n.z); + if (n.z < 0.0) n.xy = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0); + return n.xy * 0.5 + 0.5; +} + +// 1 = sunlit. Cascade by view distance, normal offset by the cascade's texel size, +// 3×3 bilinear PCF taps (a smooth 4×4 filter). +float sunShadow(vec3 n) { + if (FRAME.sunColor.w < 0.5) return 1.0; + float depth = dot(v_world - FRAME.camPos.xyz, FRAME.camFwd.xyz); + if (depth > FRAME.splits.w) return 1.0; + int c = depth < FRAME.splits.x ? 0 : depth < FRAME.splits.y ? 1 : depth < FRAME.splits.z ? 2 : 3; + float texel = FRAME.cascadeTexel[c]; + vec4 p = FRAME.cascades[c] * vec4(v_world + n * texel * 1.5, 1.0); + vec2 uv = p.xy * 0.5 + 0.5; + if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0))) || p.z > 1.0) return 1.0; + float atlasTexel = FRAME.shadow.x; + vec2 base = vec2(c & 1, c >> 1) * 0.5; + vec2 lo = base + atlasTexel, hi = base + 0.5 - atlasTexel; + vec2 at = base + uv * 0.5; + uint map = uint(FRAME.sunDir.w); + float lit = 0.0; + float soft = FRAME.shadow.w; // the sun's apparent size (r_shadowsoft); 0 = the plain 3x3 filter + if (soft <= 0.0) { + for (int y = -1; y <= 1; ++y) + for (int x = -1; x <= 1; ++x) lit += shadowTap(map, at + vec2(x, y) * atlasTexel, lo, hi, p.z - 0.0002); + lit /= 9.0; + } else { + // PCSS: how far above this point the things shading it are (blocker search), then a + // filter that widens with that gap, so shadows are sharp where they touch and soft away. + const vec2 disk[12] = vec2[](vec2(-0.326, -0.406), vec2(-0.840, -0.074), vec2(-0.696, 0.457), vec2(-0.203, 0.621), + vec2(0.962, -0.195), vec2(0.473, -0.480), vec2(0.519, 0.767), vec2(0.185, -0.893), + vec2(0.507, 0.064), vec2(0.896, 0.412), vec2(-0.322, -0.933), vec2(-0.792, -0.598)); + float spin = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))) * 6.2831853; + mat2 rot = mat2(cos(spin), sin(spin), -sin(spin), cos(spin)); + float blockers = 0.0, found = 0.0; + for (int i = 0; i < 12; ++i) { + float d = sampleTexLod(map, clamp(at + rot * disk[i] * atlasTexel * 6.0, lo, hi), 0.0).r; + if (d < p.z - 0.0002) blockers += d, found += 1.0; + } + if (found == 0.0) return 1.0; + float gap = (p.z - blockers / found) * 16000.0; // world units (the cascades' depth range) + float width = clamp(gap * 0.012 * soft / texel, 1.0, 8.0); // in shadow texels + for (int i = 0; i < 12; ++i) lit += shadowTap(map, at + rot * disk[i] * atlasTexel * width, lo, hi, p.z - 0.0002); + lit /= 12.0; + } + // Fade out over the last 10% of the shadow distance instead of a hard edge. + float fade = clamp((FRAME.splits.w - depth) / (FRAME.splits.w * 0.1), 0.0, 1.0); + return mix(1.0, lit, fade); +} + +// Smooth value noise, for ripples and the ragged edges of puddles. +float hash3(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); } +float vnoise(vec3 p) { + vec3 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(mix(hash3(i), hash3(i + vec3(1, 0, 0)), f.x), mix(hash3(i + vec3(0, 1, 0)), hash3(i + vec3(1, 1, 0)), f.x), f.y), + mix(mix(hash3(i + vec3(0, 0, 1)), hash3(i + vec3(1, 0, 1)), f.x), mix(hash3(i + vec3(0, 1, 1)), hash3(i + vec3(1, 1, 1)), f.x), f.y), f.z); +} + +// Tangent frame from screen-space derivatives (no stored tangents needed): T along +u, B along +v. +mat3 cotangentFrame(vec3 n, vec3 p, vec2 uv) { + vec3 dp1 = dFdx(p), dp2 = dFdy(p); + vec2 duv1 = dFdx(uv), duv2 = dFdy(uv); + vec3 dp2perp = cross(dp2, n), dp1perp = cross(n, dp1); + vec3 t = dp2perp * duv1.x + dp1perp * duv2.x; + vec3 b = dp2perp * duv1.y + dp1perp * duv2.y; + float invmax = inversesqrt(max(max(dot(t, t), dot(b, b)), 1e-20)); + return mat3(t * invmax, b * invmax, n); +} + +// Image-based light: what the split-sum integral of GGX × Fresnel comes to (F0 scale, bias), +// fitted analytically (Karis) instead of a lookup texture. +vec2 envBRDF(float rough, float ndv) { + const vec4 c0 = vec4(-1.0, -0.0275, -0.572, 0.022), c1 = vec4(1.0, 0.0425, 1.04, -0.04); + vec4 r = rough * c0 + c1; + float a004 = min(r.x * r.x, exp2(-9.28 * ndv)) * r.x + r.y; + return vec2(-1.04, 1.04) * a004 + r.zw; +} + +bool hasSky() { return FRAME.sky.x > 0.5; } +vec3 sky(vec3 dir, float level) { return skyRadiance(uint(FRAME.sky.x), FRAME.sky.y, FRAME.sky.w, dir, level); } +// The sky's light on a surface facing n (irradiance / π), from its spherical harmonics. +vec3 skyLight(vec3 n) { + return FRAME.skySH[0].rgb * 0.282095 + FRAME.skySH[1].rgb * 0.488603 * n.y + FRAME.skySH[2].rgb * 0.488603 * n.z + + FRAME.skySH[3].rgb * 0.488603 * n.x + FRAME.skySH[4].rgb * 1.092548 * n.x * n.y + FRAME.skySH[5].rgb * 1.092548 * n.y * n.z + + FRAME.skySH[6].rgb * 0.315392 * (3.0 * n.z * n.z - 1.0) + FRAME.skySH[7].rgb * 1.092548 * n.x * n.z + + FRAME.skySH[8].rgb * 0.546274 * (n.x * n.x - n.y * n.y); +} +float luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); } + +// Height fog / aerial perspective: denser low down, coloured by the sky behind (blurred) and +// brighter towards the sun. Returns the colour seen through `dist` of it. +#ifndef KEEL_GL +#include "froxel.glsl" +#define GI_PROBES FRAME.giProbes +#include "gi.glsl" +#endif +vec3 applyFog(vec3 color, vec3 world) { + vec3 ray = world - FRAME.camPos.xyz; + float dist = length(ray); + vec3 rd = ray / max(dist, 1e-3); + if (!hasSky() || FRAME.fog.x <= 0.0) return color; + float k = FRAME.fog.y; + float start = FRAME.fog.x * exp(-k * (FRAME.camPos.z - FRAME.fog.w)); + float kz = k * rd.z; + float amount = start * (abs(kz) > 1e-6 ? (1.0 - exp(-kz * dist)) / kz : dist); + float f = min(1.0 - exp(-amount), FRAME.fog.z); + vec3 fogColor = sky(normalize(vec3(rd.xy, max(rd.z, 0.05))), FRAME.sky.z) * 0.9; + if (FRAME.sunColor.w > 0.5) fogColor += FRAME.sunColor.rgb * pow(max(dot(rd, FRAME.sunDir.xyz), 0.0), 8.0) * 0.25; + return mix(color, fogColor, f); +} + +vec3 ambientCube(vec3 n) { + vec3 w = n * n; + return w.x * (n.x > 0.0 ? DRAW.cube[0].rgb : DRAW.cube[1].rgb) + + w.y * (n.y > 0.0 ? DRAW.cube[2].rgb : DRAW.cube[3].rgb) + + w.z * (n.z > 0.0 ? DRAW.cube[4].rgb : DRAW.cube[5].rgb); +} + +void main() { + // A mirror's pass: nothing behind the mirror (the wall it's on, the room beyond). + if (dot(FRAME.clip.xyz, FRAME.clip.xyz) > 0.5 && dot(v_world, FRAME.clip.xyz) < FRAME.clip.w) discard; + // The mirror itself: the picture its pass made, where this pixel is (slightly darkened, as + // real mirrors are), and nothing for the screen-space reflections to add. + if (FRAME.mirror.x > 0.5 && (pc.textures & 0xFFFFu) == uint(FRAME.mirror.x)) { + o_color = vec4(fetchTex(uint(FRAME.mirror.x), ivec2(gl_FragCoord.xy)).rgb * vec3(0.9, 0.92, 0.92), 1.0); + o_gbuf = vec4(0.5, 0.5, 1.0, 0.0); + return; + } + vec4 albedo = sampleTex(pc.textures & 0xFFFFu, v_uv); + if (albedo.a < 0.5) discard; // alpha-tested texture (fences, foliage) + vec4 tint = unpackUnorm4x8(pc.tint); + vec3 base = albedo.rgb * tint.rgb; + vec3 n = normalize(v_normal); + vec3 geo = n; // the surface itself, before relief + vec3 view = normalize(FRAME.camPos.xyz - v_world); + // Relief and gloss from the material map. Derivatives first, while control flow is uniform. + mat3 tbn = cotangentFrame(n, v_world, v_uv); + float gloss = 0.15, metal = 0.0; + uint materialMap = pc.material & 0xFFFFu, skyVisMap = pc.material >> 16; + if (materialMap != 0u) { + vec4 m = sampleTex(materialMap, v_uv); + vec2 xy = m.xy * 2.0 - 1.0; // X, Y; Z rebuilt (B holds metalness) + n = normalize(tbn * vec3(xy, sqrt(max(1.0 - dot(xy, xy), 0.0)))); + metal = m.z; + gloss = m.w; + } + // Weather: how open to the sky this spot is (the lightmap's sky map; moving things: their + // sun visibility), then rain wetting it (darker, glossy) and snow lying on what faces up. + float exposed = skyVisMap != 0u ? sampleTex(skyVisMap, v_lightmapUv).r : DRAW.params.y > 0.5 ? DRAW.params.x : 0.0; + exposed = smoothstep(0.25, 0.7, exposed); + if (FRAME.weather.x > 0.0) { + float wet = FRAME.weather.x * exposed * (0.45 + 0.55 * max(geo.z, 0.0)); + base *= 1.0 - 0.45 * wet; + gloss = mix(gloss, 0.93, wet * 0.85); + } + float snowCover = 0.0; + if (FRAME.weather.y > 0.0) { + float snow = FRAME.weather.y * exposed * smoothstep(0.35, 0.8, geo.z) * smoothstep(0.2, 0.6, vnoise(v_world * 0.05) * 0.5 + FRAME.weather.y); + snowCover = snow; + // Snow isn't flat white: drifts a little brighter, hollows a little bluer. + float drift = vnoise(v_world * 0.012) * 0.6 + vnoise(v_world * 0.05) * 0.4; + base = mix(base, mix(vec3(0.78, 0.82, 0.9), vec3(0.9, 0.91, 0.93), drift), snow); + gloss = mix(gloss, 0.25, snow); + metal *= 1.0 - snow; + } + int liquid = int(DRAW.params.w + 0.5); + float liquidAlpha = 1.0; + if (liquid == 1) { + // A film of liquid: thickest in the middle (UV v = 0), ragged at the edge, rippled. + float thickness = 1.0 - v_uv.y; + float ragged = vnoise(v_world * 0.35) * 0.6 + vnoise(v_world * 1.1) * 0.4; + float edge = 0.12 + 0.45 * ragged; + if (thickness < edge) discard; + liquidAlpha = smoothstep(edge, edge + 0.2, thickness); + float e = 0.05; + vec3 q = v_world * 0.6; + vec3 grad = vec3(vnoise(q + vec3(e, 0, 0)) - vnoise(q - vec3(e, 0, 0)), vnoise(q + vec3(0, e, 0)) - vnoise(q - vec3(0, e, 0)), + vnoise(q + vec3(0, 0, e)) - vnoise(q - vec3(0, 0, e))) / (2.0 * e); + n = normalize(n - (grad - n * dot(grad, n)) * 0.12); + // Deeper liquid is richer in colour (Beer–Lambert-ish); thin edges just wet the surface. + base = mix(base * 0.35, base, 0.4 + 0.6 * thickness); + gloss = 0.95; + } else if (liquid == 2) { + // A body of liquid: light passes through it, more of it where it's thicker. Looking + // straight through the middle is the longest path (rich, dense); the edges are thin. + float through = abs(dot(n, view)); + base = mix(base * 1.15, base * 0.5, through); + liquidAlpha = mix(0.45, 1.0, through); + gloss = 0.92; + } + if (liquid == 4) { + // A decal (a burn mark) on a disc: matte, darkest in the middle, its edge soft and ragged. + float thickness = 1.0 - v_uv.y; + float ragged = vnoise(v_world * 0.12) * 0.6 + vnoise(v_world * 0.45) * 0.4; + liquidAlpha = smoothstep(0.0, 0.9, thickness + (ragged - 0.5) * 0.6); + gloss = 0.08; + } + if (liquid == 3) { + // Open water: waves (a few travelling swells plus noise ripples) bend the normal; looking + // straight down you see into it, at a grazing angle it mirrors the sky (Fresnel, below). + float t = DRAW.probe.z; + vec2 p = v_world.xy, g = vec2(0.0); + vec2 d1 = vec2(0.8, 0.6), d2 = vec2(-0.5, 0.86), d3 = vec2(0.2, -0.98); + g += d1 * cos(dot(p, d1) * 0.035 + t * 1.1) * 0.09; + g += d2 * cos(dot(p, d2) * 0.07 + t * 1.7) * 0.06; + g += d3 * cos(dot(p, d3) * 0.13 + t * 2.3) * 0.04; + // Two layers of ripples drifting different ways (the wind, and the surface's own churn). + float e = 0.5; + vec3 q = vec3(p * 0.09 + vec2(t * 0.25, t * 0.1), t * 0.35), q2 = vec3(p * 0.21 - vec2(t * 0.12, t * 0.3), t * 0.6); + g += vec2(vnoise(q + vec3(e, 0, 0)) - vnoise(q - vec3(e, 0, 0)), vnoise(q + vec3(0, e, 0)) - vnoise(q - vec3(0, e, 0))) * 0.3; + g += vec2(vnoise(q2 + vec3(e, 0, 0)) - vnoise(q2 - vec3(e, 0, 0)), vnoise(q2 + vec3(0, e, 0)) - vnoise(q2 - vec3(0, e, 0))) * 0.18; + n = normalize(geo + vec3(-g, 0.0)); + float through = abs(dot(n, view)); + base = mix(base * 1.25, base * 0.6, through); + liquidAlpha = mix(0.95, 0.62, through); + gloss = 0.97; + } + if (DRAW.params.z > 0.5) { + // Liquid: cut away above the surface; looking in through the cut, the inside (back) + // faces stand in for the flat surface. + if (dot(v_world, DRAW.plane.xyz) > DRAW.plane.w) discard; + if (!gl_FrontFacing) { + n = DRAW.plane.xyz; + base *= 1.2; + } + } + // Metals reflect in their own colour and scatter nothing: F0 is the albedo, diffuse goes. + g_f0 = mix(vec3(0.04), base, metal); + base *= 1.0 - metal; + vec3 sunDir = FRAME.sunDir.xyz; + // Relief can't light what the surface itself faces away from (no light leaking round corners). + float facing = dot(geo, sunDir) > 0.0 ? max(dot(n, sunDir), 0.0) : 0.0; + vec3 sun = FRAME.sunColor.rgb * FRAME.sunColor.w * facing; + float rough = clamp(1.0 - gloss, 0.05, 1.0); + vec3 sunSpec = FRAME.sunColor.rgb * FRAME.sunColor.w * facing * specGGX(n, sunDir, view, rough); + // How much open sky this spot sees (reflections of the sky fade with it). + float skyVis = 0.0; + // Cavities in the relief (grout, pores) get less of the soft, baked light. + float cavity = clamp(1.0 - (1.0 - dot(n, geo)) * 2.5, 0.45, 1.0); + vec3 specular = vec3(0.0), env = vec3(0.0); + bool realtime = FRAME.shadow.z > 0.5; + uint lightmap = pc.textures >> 16; + vec3 light; + if (lightmap != 0u) { + vec4 rgbm = sampleTex(lightmap, v_lightmapUv); // RGBM, range 8 + vec3 baked = rgbm.rgb * rgbm.a * 8.0 * cavity; + // Night: what the sky gave (most of the light where it's open) goes; lamps stay. + baked *= 1.0 - FRAME.weather.z * 0.9 * smoothstep(0.0, 0.45, skyVisMap != 0u ? sampleTex(skyVisMap, v_lightmapUv).r : 0.0); + env = baked; + skyVis = skyVisMap != 0u ? sampleTex(skyVisMap, v_lightmapUv).r : 0.0; + if (realtime) { + float vis = facing > 0.0 ? sunShadow(geo) : 0.0; + light = baked + sun * vis; + specular = sunSpec * vis; + } else { // old maps: the sun is baked in; players and props darken it where they block it + float shade = 1.0 - FRAME.shadow.y * (1.0 - sunShadow(n)) * facing * FRAME.sunColor.w; + light = baked * shade; + } + } else if (DRAW.params.y > 0.5) { + // Moving things: baked ambient cube plus the sun. With a real-time sun the shadow map + // already has the world in it; otherwise the probe's sun visibility stands in for it. + float vis = facing > 0.0 ? sunShadow(geo) : 0.0; + vis = realtime ? vis : DRAW.params.x * vis; + env = ambientCube(n) * cavity * (1.0 - FRAME.weather.z * 0.8 * max(DRAW.params.x, 0.35)); + light = env + sun * vis; + // Probes don't know where the sky is: compare their light with the open sky's. + if (hasSky()) skyVis = clamp(luma(env) / max(luma(skyLight(n)), 1e-3), 0.0, 1.0) * mix(0.6, 1.0, DRAW.params.x); + specular = sunSpec * vis; + } else if (hasSky()) { + // No baked light of its own (water, map-less things): the sky's light here and the real + // sun or moon, through its shadow — so it darkens at night like everything else. + float vis = facing > 0.0 ? sunShadow(geo) : 0.0; + env = skyLight(n); + light = env + sun * vis; + specular = sunSpec * vis; + } else { + light = vec3(sunLight(n)); + } + uint count; + vec3 dynSpec; + light += dynamicLights(v_world, n, rough, view, count, dynSpec); +#ifndef KEEL_GL + if (FRAME.gi.x > 0.5) { + vec3 bounced = giIrradiance(v_world, n, view) * cavity; // their light, bounced + if (FRAME.gi.z > 0.5) { o_color = vec4(bounced * 4.0, 1.0); o_gbuf = vec4(0.0); return; } + light += bounced; + } +#endif + // Reflections (split-sum image-based light): the sky, prefiltered for this roughness, where + // the sky is open; the light arriving here stands in for the room where it isn't. + float ndv = clamp(dot(n, view), 1e-4, 1.0); + vec3 r = reflect(-view, n); + r = normalize(r + geo * max(-dot(r, geo), 0.0) * 1.05); // keep it above the surface + vec2 ab = envBRDF(rough, ndv); + float occlusion = skyVis * skyVis; + // What's around: the nearest reflection probe (env_cubemap) where there is one, else the + // light arriving here; the sky takes over as far as this spot sees it. + // (probes were baked in daylight: at night what they show goes dark too) + vec3 near = DRAW.probe.x > 0.5 ? skyRadiance(uint(DRAW.probe.x), 1.0 - 0.92 * FRAME.weather.z, 0.0, r, rough * DRAW.probe.y) : env; + vec3 surroundings = hasSky() ? mix(near, sky(r, rough * FRAME.sky.z), occlusion) : near; + vec3 reflection = surroundings * (g_f0 * ab.x + ab.y) * cavity; + vec3 color = base * light + specular + dynSpec + reflection; + // Snow glitters: here and there a crystal catches the sun (or a lamp) towards the eye. + if (snowCover > 0.05) { + vec3 cell = floor(v_world * 1.7); + float h = fract(sin(dot(cell, vec3(12.9898, 78.233, 45.164))) * 43758.5453); + float glint = step(0.975, h) * pow(max(dot(n, normalize(sunDir + view)), 0.0), 40.0); + color += (FRAME.sunColor.rgb * FRAME.sunColor.w * (facing > 0.0 ? 1.0 : 0.0) + dynSpec * 4.0) * glint * snowCover * 1.2; + } + float alpha = tint.a * liquidAlpha; + if (liquid == 3) { + // Open water barely scatters light of its own: it's what's mirrored (more the flatter + // you look: Fresnel) over what's seen through it (the bottom, more looking straight + // down), tinted by a dim veil of its own colour. Blended over what's behind it, so: + // alpha = the share it covers, colour = what covers it. + vec3 mirrored = hasSky() ? mix(env, sky(r, 0.0), occlusion) : vec3(0.45, 0.6, 0.8); + float fres = 0.02 + 0.98 * pow(1.0 - clamp(abs(dot(n, view)), 0.0, 1.0), 5.0); + float clear = 0.72 * clamp(abs(dot(geo, view)), 0.0, 1.0); // (how much of the bottom shows through) + vec3 veil = base * (light * 0.3 + vec3(0.015)); + float cover = fres + (1.0 - fres) * (1.0 - clear); + color = (mirrored * fres + veil * (1.0 - fres) * (1.0 - clear)) / max(cover, 1e-3); + color += FRAME.sunColor.rgb * FRAME.sunColor.w * facing * specGGX(n, sunDir, view, 0.06) + dynSpec; // (the sun's glint, lamps') + alpha = clamp(cover, 0.0, 1.0); + } else if (liquid != 0 && liquid != 4) { + // Wet: a sharp sun glint and the sky mirrored at grazing angles, on top of what's lit. + vec3 mirrored = hasSky() ? mix(env, sky(r, 0.0), occlusion) : vec3(0.45, 0.6, 0.8); + float fres = 0.02 + 0.98 * pow(1.0 - clamp(dot(n, view), 0.0, 1.0), 5.0); + color += mirrored * fres * 0.9 + FRAME.sunColor.rgb * FRAME.sunColor.w * facing * specGGX(n, sunDir, view, 0.08); + if (liquid == 1) alpha = clamp(alpha * (0.75 + fres), 0.0, 0.95); + } + if (alpha < 0.5 && liquid != 4 && liquid != 3) { + // Glass (clear materials; denser translucent things like liquid or cracks shade normally): more reflective at grazing angles (Fresnel), plus a sharp sun glint. + vec3 v = normalize(FRAME.camPos.xyz - v_world); + vec3 nf = dot(n, v) < 0.0 ? -n : n; // the side facing us, whatever the winding + float fresnel = pow(1.0 - clamp(dot(nf, v), 0.0, 1.0), 4.0); + float glint = pow(max(dot(nf, normalize(sunDir + v)), 0.0), 150.0) * FRAME.sunColor.w; + // Glass barely scatters light itself: mostly what's behind it, plus reflections. + vec3 rg = reflect(-v, nf); + vec3 mirrored = hasSky() ? mix(env, sky(rg, 0.0), max(occlusion, 0.25)) : vec3(0.45, 0.6, 0.8); + color = color * 0.12 + FRAME.sunColor.rgb * glint * 3.0 + mirrored * fresnel * 0.8; + // A neutral sheen where it turns away from you (no colour: grey to white), so glass reads + // as glass at an angle. + color += vec3(0.06 + fresnel * 0.35) * (hasSky() ? max(occlusion, 0.4) : 0.6); + alpha = clamp(alpha * 0.6 + fresnel * 0.5 + glint, 0.0, 1.0); + } +#ifndef KEEL_GL + if (FRAME.froxel.x > 0.5) { // the fog grid: lit, shadowed fog (and the air's light) + vec4 f = froxelFog(gl_FragCoord.xy / FRAME.screen.xy, dot(v_world - FRAME.camPos.xyz, FRAME.camFwd.xyz)); + color = color * f.a + f.rgb; + } else +#endif + color = applyFog(color, v_world); + o_color = vec4(color, alpha); + // View space as the post passes see it: x right, y down the screen, z forward. + vec3 fwd = FRAME.camFwd.xyz, right = normalize(cross(fwd, vec3(0.0, 0.0, 1.0))), up = cross(right, fwd); + vec3 nv = vec3(dot(n, right), -dot(n, up), dot(n, fwd)); + float reflectWeight = dot(g_f0 * ab.x + ab.y, vec3(0.333)) * cavity * (liquid != 0 ? 1.5 : 1.0); + o_gbuf = vec4(octEncode(nv), rough, reflectWeight); + if (FRAME.camFwd.w > 0.5) o_color.rgb = mix(o_color.rgb, heatmap(count), 0.6); +} diff --git a/engine/shaders/mesh.vert b/engine/shaders/mesh.vert new file mode 100644 index 0000000..22e1165 --- /dev/null +++ b/engine/shaders/mesh.vert @@ -0,0 +1,18 @@ +#version 460 +#include "model.glsl" + +layout(location = 0) out vec3 v_normal; +layout(location = 1) out vec2 v_uv; +layout(location = 2) out vec2 v_lightmapUv; +layout(location = 3) out vec3 v_world; + +void main() { + Vertex vx = VERTEX(VERTEX_ID); + mat4 model = DRAW.model; + vec4 world = model * vec4(vx.px, vx.py, vx.pz, 1.0); + v_normal = normalize(mat3(model) * vec3(vx.nx, vx.ny, vx.nz)); + v_uv = vec2(vx.u, vx.v); + v_lightmapUv = vec2(vx.lu, vx.lv); + v_world = world.xyz; + gl_Position = pc.viewProj * world; +} diff --git a/engine/shaders/model.glsl b/engine/shaders/model.glsl new file mode 100644 index 0000000..677d2be --- /dev/null +++ b/engine/shaders/model.glsl @@ -0,0 +1,169 @@ +#ifndef KEEL_MODEL_GLSL +#define KEEL_MODEL_GLSL +// Shared by mesh.vert, skinned.vert and mesh.frag: per-draw and per-frame data. The layouts are +// identical on both backends (render/backend.h mirrors them); only how they're reached differs. +#include "backend.glsl" + +struct Vertex { float px, py, pz, nx, ny, nz, u, v, lu, lv; }; +// Dynamic lights (clustered, see render/clusters.h). +struct Light { + vec4 posRadius; + vec4 color; // rgb, w = first shadow view or -1 + vec4 spot; // xyz direction, w = cos(outer cone); -2 = point light + vec4 extra; // x = cos(inner cone), y = the light's size (area light radius) +}; +struct ShadowView { mat4 viewProj; vec4 rect; }; // rect: atlas uv offset, size +// How much of a spot light reaches a direction at cos c from its axis. A plain spot fades from +// its inner cone to its outer one; a reflector (a flashlight, extra.z = 1) has a hot centre, a +// faint bright ring where its mirror ends, faint rings in the beam and a dim spill out to the edge. +float spotShape(Light l, float c) { + if (l.extra.z < 0.5) return smoothstep(l.spot.w, l.extra.x, c); + float outer = acos(clamp(l.spot.w, -1.0, 1.0)), inner = acos(clamp(l.extra.x, -1.0, 1.0)); + float t = acos(clamp(c, -1.0, 1.0)) / max(outer, 1e-3), k = inner / max(outer, 1e-3); + float core = mix(0.14, 1.0, 1.0 - smoothstep(k * 0.5, k * 1.1, t)); + float ring = exp(-pow((t - k * 1.15) * 16.0, 2.0)) * 0.22; + float rings = 0.95 + 0.05 * sin(t * 60.0); + return (core + ring) * rings * (1.0 - smoothstep(0.7, 1.0, t)); +} + +// Per draw: transform, baked ambient cube (+X -X +Y -Y +Z -Z), params x = sun visibility, +// y = lit by probes, z = liquid (cut at `plane`: xyz normal, w distance, world space), +// w = liquid style (1 film, 2 body; see LiquidStyle). +#define DRAW_FIELDS mat4 model; vec4 cube[6]; vec4 params; vec4 plane; vec4 probe; +// Per frame: sun and its shadow map, camera, dynamic lights. +#define FRAME_FIELDS \ + mat4 cascades[4]; /* sun shadow cascades, each a quarter of the shadow map */ \ + vec4 splits; /* view distance where each cascade ends */ \ + vec4 cascadeTexel; /* world size of a shadow texel per cascade */ \ + vec4 sunDir; /* xyz towards the sun, w = shadow map texture index */ \ + vec4 sunColor; /* rgb, w = 1 when there is a sun */ \ + vec4 shadow; /* x = texel size, y = strength on baked surfaces, z = 1 real-time */ \ + vec4 volumetric; /* volumetric light: x strength, y reach, z air density, w sun too */ \ + vec4 camPos; /* xyz, w = dynamic light count */ \ + vec4 camFwd; /* xyz, w = debug view (1 = lights per cluster) */ \ + vec4 screen; /* width, height, shadow atlas texture index, atlas size */ \ + vec4 sky; /* x sky texture (0 = none), y intensity, z top level, w yaw */ \ + vec4 fog; /* x density per unit, y height falloff per unit, z max, w base z */ \ + vec4 skySH[9]; /* the sky's light: rgb spherical harmonics, irradiance / pi */ \ + vec4 froxel; /* the fog grid (froxel.glsl): x 1 = on (Vulkan), y near, z far */ \ + vec4 gi; /* real-time GI probes (gi.glsl): x 1 = on, y spacing */ \ + vec4 giOrigin; /* the probe grid's first cell (whole numbers) */ \ + vec4 mirror; /* x: the mirror's picture's texture (a mirror surface shows it); y 1: \ + the mirror's own pass (every light, not this view's clusters) */ \ + vec4 clip; /* the mirror's pass: what's behind its plane (xyz·p < w) is cut */ \ + vec4 weather; /* x wet (rain), y snow cover, z night (0 day .. 1), w lightning flash */ + +#ifdef KEEL_FRAME_ONLY +// Post passes that need the frame (volumetric.frag): it and the dynamic lights. Vulkan: a +// FrameData reference in the pass's own push block (#define FRAME pc.frame there); OpenGL: the +// frame's and the lights' bindings. +#ifdef KEEL_GL +layout(std430, binding = 4) readonly buffer FrameBlock { FRAME_FIELDS uvec2 unusedRefs[6]; } u_frame; +layout(std430, binding = 5) readonly buffer Lights { Light l[]; } u_lights; +layout(std430, binding = 6) readonly buffer Cells { uvec2 c[]; } u_cells; +layout(std430, binding = 7) readonly buffer LightIndices { uint i[]; } u_lightIndices; +layout(std430, binding = 8) readonly buffer ShadowViews { ShadowView v[]; } u_shadowViews; +#define FRAME u_frame +#define LIGHT(n_) u_lights.l[n_] +#define CELL(n_) u_cells.c[n_] +#define LIGHT_INDEX(n_) u_lightIndices.i[n_] +#define SHADOW_VIEW(n_) u_shadowViews.v[n_] +#else +layout(buffer_reference, std430) readonly buffer Lights { Light l[]; }; +layout(buffer_reference, std430) readonly buffer Cells { uvec2 c[]; }; +layout(buffer_reference, std430) readonly buffer LightIndices { uint i[]; }; +layout(buffer_reference, std430) readonly buffer ShadowViews { ShadowView v[]; }; +layout(buffer_reference, std430) readonly buffer Froxels { uvec2 f[]; }; // the fog grid, integrated: rgb light, a transmittance (halves) +// A GI probe (gi.glsl): the light reaching it as L1 spherical harmonics (sh[0..3].rgb: band 0, +// then y, z, x; sh[0].w how much to trust it), the mean distance to what's around it and its +// square along ±x ±y ±z, and the world cell it holds (w 1 = filled). +struct GiProbe { vec4 sh[4]; vec4 dist[3]; vec4 tag; }; +layout(buffer_reference, std430) readonly buffer GiProbes { GiProbe p[]; }; +layout(buffer_reference, std430) readonly buffer FrameData { + FRAME_FIELDS + Lights lights; + Cells cells; + LightIndices lightIndices; + ShadowViews shadowViews; + Froxels froxels; + GiProbes giProbes; +}; +#define LIGHT(n_) FRAME.lights.l[n_] +#define CELL(n_) FRAME.cells.c[n_] +#define LIGHT_INDEX(n_) FRAME.lightIndices.i[n_] +#define SHADOW_VIEW(n_) FRAME.shadowViews.v[n_] +#endif +#elif defined(KEEL_GL) +struct DrawData { DRAW_FIELDS }; +layout(std430, binding = 0) readonly buffer Vertices { Vertex v[]; } u_vertices; +layout(std430, binding = 1) readonly buffer Skin { uvec2 s[]; } u_skin; // joints (4×u8), weights (4×u8) +layout(std430, binding = 2) readonly buffer Joints { mat4 m[]; } u_joints; +layout(std430, binding = 3) readonly buffer Draws { DrawData d[]; } u_draws; +layout(std430, binding = 4) readonly buffer FrameBlock { FRAME_FIELDS uvec2 unusedRefs[6]; } u_frame; +layout(std430, binding = 5) readonly buffer Lights { Light l[]; } u_lights; +layout(std430, binding = 6) readonly buffer Cells { uvec2 c[]; } u_cells; // first index, count +layout(std430, binding = 7) readonly buffer LightIndices { uint i[]; } u_lightIndices; +layout(std430, binding = 8) readonly buffer ShadowViews { ShadowView v[]; } u_shadowViews; +PUSH_BLOCK { + mat4 viewProj; + uint textures; // albedo | lightmap << 16 + uint tint; // RGBA8 + uint material; // texture: rgb tangent-space normal, a gloss (0 = flat, matte-ish) | sky visibility << 16 + uint drawIndex; // into Draws + uint firstJoint; // into Joints (skinned draws) +} pc; +#define VERTEX(n_) u_vertices.v[n_] +#define SKIN(n_) u_skin.s[n_] +#define JOINT(n_) u_joints.m[pc.firstJoint + (n_)] +#define DRAW u_draws.d[pc.drawIndex] +#define FRAME u_frame +#define LIGHT(n_) u_lights.l[n_] +#define CELL(n_) u_cells.c[n_] +#define LIGHT_INDEX(n_) u_lightIndices.i[n_] +#define SHADOW_VIEW(n_) u_shadowViews.v[n_] +#else +layout(buffer_reference, std430) readonly buffer Vertices { Vertex v[]; }; +layout(buffer_reference, std430) readonly buffer Skin { uvec2 s[]; }; // joints (4×u8), weights (4×u8) +layout(buffer_reference, std430) readonly buffer Joints { mat4 m[]; }; +layout(buffer_reference, std430) readonly buffer DrawData { DRAW_FIELDS }; +layout(buffer_reference, std430) readonly buffer Lights { Light l[]; }; +layout(buffer_reference, std430) readonly buffer Cells { uvec2 c[]; }; // first index, count +layout(buffer_reference, std430) readonly buffer LightIndices { uint i[]; }; +layout(buffer_reference, std430) readonly buffer ShadowViews { ShadowView v[]; }; +layout(buffer_reference, std430) readonly buffer Froxels { uvec2 f[]; }; // the fog grid, integrated: rgb light, a transmittance (halves) +// A GI probe (gi.glsl): the light reaching it as L1 spherical harmonics (sh[0..3].rgb: band 0, +// then y, z, x; sh[0].w how much to trust it), the mean distance to what's around it and its +// square along ±x ±y ±z, and the world cell it holds (w 1 = filled). +struct GiProbe { vec4 sh[4]; vec4 dist[3]; vec4 tag; }; +layout(buffer_reference, std430) readonly buffer GiProbes { GiProbe p[]; }; +layout(buffer_reference, std430) readonly buffer FrameData { + FRAME_FIELDS + Lights lights; + Cells cells; + LightIndices lightIndices; + ShadowViews shadowViews; + Froxels froxels; + GiProbes giProbes; +}; +PUSH_BLOCK { + mat4 viewProj; + Vertices vertices; + Skin skin; // skinned draws only + Joints joints; // skinned draws only; already include the model transform + DrawData draw; + FrameData frame; + uint textures; // albedo | lightmap << 16 + uint tint; // RGBA8 + uint material; // texture: rgb tangent-space normal, a gloss (0 = flat, matte-ish) | sky visibility << 16 +} pc; +#define VERTEX(n_) pc.vertices.v[n_] +#define SKIN(n_) pc.skin.s[n_] +#define JOINT(n_) pc.joints.m[n_] +#define DRAW pc.draw +#define FRAME pc.frame +#define LIGHT(n_) pc.frame.lights.l[n_] +#define CELL(n_) pc.frame.cells.c[n_] +#define LIGHT_INDEX(n_) pc.frame.lightIndices.i[n_] +#define SHADOW_VIEW(n_) pc.frame.shadowViews.v[n_] +#endif +#endif diff --git a/engine/shaders/skinned.vert b/engine/shaders/skinned.vert new file mode 100644 index 0000000..b5406cc --- /dev/null +++ b/engine/shaders/skinned.vert @@ -0,0 +1,21 @@ +#version 460 +#include "model.glsl" + +layout(location = 0) out vec3 v_normal; +layout(location = 1) out vec2 v_uv; +layout(location = 2) out vec2 v_lightmapUv; +layout(location = 3) out vec3 v_world; + +void main() { + Vertex vx = VERTEX(VERTEX_ID); + uvec2 sk = SKIN(VERTEX_ID); + vec4 w = unpackUnorm4x8(sk.y); + mat4 m = JOINT(sk.x & 0xFFu) * w.x + JOINT((sk.x >> 8) & 0xFFu) * w.y + + JOINT((sk.x >> 16) & 0xFFu) * w.z + JOINT(sk.x >> 24) * w.w; + vec4 world = m * vec4(vx.px, vx.py, vx.pz, 1.0); + v_normal = normalize(mat3(m) * vec3(vx.nx, vx.ny, vx.nz)); + v_uv = vec2(vx.u, vx.v); + v_lightmapUv = vec2(0.0); + v_world = world.xyz; + gl_Position = pc.viewProj * world; +} diff --git a/engine/shaders/sky.frag b/engine/shaders/sky.frag new file mode 100644 index 0000000..9d93352 --- /dev/null +++ b/engine/shaders/sky.frag @@ -0,0 +1,58 @@ +#version 460 +#include "backend.glsl" +PUSH_BLOCK { + mat4 invViewProj; + vec4 sunDir; // xyz towards the sun + vec4 sunColor; // rgb, w = 1 when there is a sun + vec4 sky; // x texture, y intensity, z top level, w yaw + vec4 weather; // x cloud cover, y the photo's own sun's height (z; 2 = none), z night, w lightning flash +} pc; +#include "sky.glsl" + +layout(location = 0) in vec2 v_ndc; +layout(location = 0) out vec4 o_color; + +void main() { + vec4 far = pc.invViewProj * vec4(v_ndc, 1.0, 1.0), near = pc.invViewProj * vec4(v_ndc, 0.0, 1.0); + vec3 dir = normalize(far.xyz / far.w - near.xyz / near.w); + vec3 c = skyRadiance(uint(pc.sky.x), pc.sky.y, pc.sky.w, dir, 0.0); + // Overcast: the photo's sky greys over (a flat, brighter-low-down cloud deck). + float cloud = pc.weather.x; + // The photo's own sun (turned to the real sun's bearing, but at the height it was shot at): + // when the time of day has moved the sun away from it, it's painted over with the sky beside it. + if (pc.weather.y < 1.5 && length(pc.sunDir.xy) > 1e-3) { + vec3 photoSun = vec3(normalize(pc.sunDir.xy) * sqrt(max(1.0 - pc.weather.y * pc.weather.y, 0.0)), pc.weather.y); + float moved = 1.0 - smoothstep(0.9990, 0.9998, dot(normalize(pc.sunDir.xyz), photoSun)); + float near = smoothstep(0.975, 0.997, dot(dir, photoSun)) * moved; + if (near > 0.0) { // the same height a little round to the side: clean sky like what's round it + const float t = 0.38; // ~22° + vec3 beside = vec3(dir.x * cos(t) - dir.y * sin(t), dir.x * sin(t) + dir.y * cos(t), dir.z); + c = mix(c, skyRadiance(uint(pc.sky.x), pc.sky.y, pc.sky.w, beside, 1.0), near); + } + } + // The photo blurred right out (its top level): no sun in it, for the cloud deck and the night. + vec3 soft = (cloud > 0.0 || pc.weather.z > 0.0) ? skyRadiance(uint(pc.sky.x), pc.sky.y, pc.sky.w, dir, pc.sky.z) : c; + if (cloud > 0.0) { + float l = dot(soft, vec3(0.2126, 0.7152, 0.0722)); + vec3 deck = vec3(l) * vec3(0.92, 0.95, 1.0) * mix(1.0, 0.7, smoothstep(0.0, 0.6, dir.z)); + c = mix(c, deck, cloud); + } + if (pc.sunColor.w > 0.5 && cloud < 0.95) { + // The sun itself (taken out of the photo so it matches the shadows): a disc and a glow. + float d = dot(dir, pc.sunDir.xyz); + c += pc.sunColor.rgb * (smoothstep(0.99995, 0.99999, d) * 60.0 + pow(max(d, 0.0), 600.0) * 1.5 + pow(max(d, 0.0), 40.0) * 0.08) * (1.0 - cloud) * (1.0 - cloud); + } + // Night: the photo's sky goes dark blue, and stars come out (fixed to the sky). + float night = pc.weather.z; + if (night > 0.0) { + c = mix(c, vec3(0.004, 0.006, 0.014) + soft * 0.15, night); + vec3 d = dir * 220.0; + vec3 cell = floor(d), f = fract(d) - 0.5; + float h = fract(sin(dot(cell, vec3(12.9898, 78.233, 37.719))) * 43758.5453); + float star = step(0.985, h) * smoothstep(0.12, 0.0, length(f)) * smoothstep(-0.05, 0.25, dir.z); + float bright = 0.3 + 0.7 * fract(h * 97.0); // stars of different brightness + c += vec3(0.8, 0.85, 1.0) * star * bright * night * 0.6; + } + c *= 1.0 + pc.weather.w * 4.0; // lightning lights the clouds + o_color = vec4(c, 1.0); +} diff --git a/engine/shaders/sky.glsl b/engine/shaders/sky.glsl new file mode 100644 index 0000000..0335844 --- /dev/null +++ b/engine/shaders/sky.glsl @@ -0,0 +1,16 @@ +#ifndef KEEL_SKY_GLSL +#define KEEL_SKY_GLSL +// The sky (.ksky, engine/asset/ksky.h): equirectangular RGBM; level k is the sky as a surface +// of roughness k / top reflects it. +const float kSkyRange = 16.0; + +// Radiance from direction `dir` (world space). yaw turns the photo so its sun lines up with +// the map's. +vec3 skyRadiance(uint tex, float intensity, float yaw, vec3 dir, float level) { + float c = cos(yaw), s = sin(yaw); + dir = vec3(c * dir.x + s * dir.y, -s * dir.x + c * dir.y, dir.z); + vec2 uv = vec2(atan(dir.y, dir.x) * 0.15915494 + 0.5, acos(clamp(dir.z, -1.0, 1.0)) * 0.31830989); + vec4 m = sampleTexLod(tex, uv, level); + return m.rgb * m.a * kSkyRange * intensity; +} +#endif diff --git a/engine/shaders/sky.vert b/engine/shaders/sky.vert new file mode 100644 index 0000000..8385acb --- /dev/null +++ b/engine/shaders/sky.vert @@ -0,0 +1,10 @@ +#version 460 +// The sky: a full-screen triangle on the far plane, drawn where nothing else is (depth test +// less-or-equal against the cleared 1.0). +#include "backend.glsl" +layout(location = 0) out vec2 v_ndc; +void main() { + vec2 uv = vec2((VERTEX_ID << 1) & 2, VERTEX_ID & 2); + v_ndc = uv * 2.0 - 1.0; + gl_Position = vec4(v_ndc, 1.0, 1.0); +} diff --git a/engine/shaders/smoke_sky.frag b/engine/shaders/smoke_sky.frag new file mode 100644 index 0000000..2fd2529 --- /dev/null +++ b/engine/shaders/smoke_sky.frag @@ -0,0 +1,30 @@ +#version 460 +// Smoke in front of the sky (Vulkan): the sky pass has no fog (its photo has its own haze), so +// smoke against open sky is laid over it here, from the fog grid's smoke-only column totals +// (froxel_main.glsl: light, and how much of what's behind gets through). Drawn on the far plane +// after the sky, where nothing else is: colour × through + light. +#include "backend.glsl" +#define KEEL_FRAME_ONLY +#include "model.glsl" +layout(buffer_reference, std430) readonly buffer SmokeSky { uvec2 f[]; }; +PUSH_BLOCK { + FrameData frame; + SmokeSky sky; // 160 × 90 (the fog grid's columns) +} pc; +#define FRAME pc.frame +layout(location = 0) in vec2 v_ndc; +layout(location = 0) out vec4 o_color; + +const ivec2 kCols = ivec2(160, 90); +vec4 column(ivec2 c) { + uvec2 v = pc.sky.f[clamp(c.y, 0, kCols.y - 1) * kCols.x + clamp(c.x, 0, kCols.x - 1)]; + return vec4(unpackHalf2x16(v.x), unpackHalf2x16(v.y)); +} + +void main() { + vec2 g = gl_FragCoord.xy / FRAME.screen.xy * vec2(kCols) - 0.5; + ivec2 b = ivec2(floor(g)); + vec2 f = g - vec2(b); + vec4 s = mix(mix(column(b), column(b + ivec2(1, 0)), f.x), mix(column(b + ivec2(0, 1)), column(b + ivec2(1, 1)), f.x), f.y); + o_color = vec4(s.rgb, s.a); // (blended: src + dst × src.a) +} diff --git a/engine/shaders/sprite.frag b/engine/shaders/sprite.frag new file mode 100644 index 0000000..a55454b --- /dev/null +++ b/engine/shaders/sprite.frag @@ -0,0 +1,19 @@ +#version 460 +// Particles: unlit, the texture times the colour (fire, sparks, flashes added on; smoke blended). +#include "backend.glsl" +PUSH_BLOCK { + mat4 viewProj; + vec4 posSize; + vec4 color; + vec4 right; + vec4 up; +} pc; +layout(location = 0) in vec2 v_uv; +layout(location = 0) out vec4 o_color; +void main() { + vec4 t = sampleTex(floatBitsToUint(pc.up.w), v_uv); + // Block compression leaves a little alpha where there should be none; bright sprites + // would show it as a square: cut it. + float a = clamp((t.a - 0.03) / 0.97, 0.0, 1.0); + o_color = vec4(t.rgb * pc.color.rgb, a * pc.color.a); +} diff --git a/engine/shaders/sprite.vert b/engine/shaders/sprite.vert new file mode 100644 index 0000000..cf238cf --- /dev/null +++ b/engine/shaders/sprite.vert @@ -0,0 +1,21 @@ +#version 460 +// Particles: a camera-facing quad per sprite, from the vertex index (6 vertices, no buffer). +#include "backend.glsl" +PUSH_BLOCK { + mat4 viewProj; + vec4 posSize; // xyz centre, w half size + vec4 color; // rgb (HDR: can be bright), a + vec4 right; // camera right, w = rotation (radians) + vec4 up; // camera up, w = texture index (bits) +} pc; +layout(location = 0) out vec2 v_uv; +const vec2 kQuad[6] = vec2[](vec2(-1,-1), vec2(1,-1), vec2(1,1), vec2(-1,-1), vec2(1,1), vec2(-1,1)); +void main() { + vec2 q = kQuad[VERTEX_ID]; + float c = cos(pc.right.w), s = sin(pc.right.w); + vec2 r = vec2(c * q.x - s * q.y, s * q.x + c * q.y); + vec3 world = pc.posSize.xyz + (pc.right.xyz * r.x + pc.up.xyz * r.y) * pc.posSize.w; + v_uv = q * 0.5 + 0.5; + v_uv.y = 1.0 - v_uv.y; + gl_Position = pc.viewProj * vec4(world, 1.0); +} diff --git a/engine/shaders/ssao.frag b/engine/shaders/ssao.frag new file mode 100644 index 0000000..b177797 --- /dev/null +++ b/engine/shaders/ssao.frag @@ -0,0 +1,63 @@ +#version 460 +// Ambient occlusion from the depth buffer alone (Alchemy-style, half resolution): for each +// pixel, view-space points around it in a spiral; those above the surface's plane and close +// enough darken it. Rows are the depth buffer's own (uv from gl_FragCoord), on both backends. +#define KEEL_POST // screen-space: render targets, sampled clamped +#include "backend.glsl" +PUSH_BLOCK { + uint depth; // scene depth slot + uint w, h; // its size + uint outW, outH; // this target's + float radius; // world units + float strength; + float pad; + vec4 proj; // tan(half fov x), tan(half fov y), near, far +} pc; +layout(location = 0) out vec4 o_ao; + +// The point seen at uv: its depth from the pixel there, and its place across the screen from +// that same pixel's centre (not uv itself: on a slanted wall the two would disagree by a part of +// a pixel, in a pattern that depends on how the window's size halves; stripes on walls). +vec3 viewPos(vec2 uv) { + ivec2 p = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1)); + float d = fetchTex(pc.depth, p).r; + float z = pc.proj.z * pc.proj.w / (pc.proj.w - d * (pc.proj.w - pc.proj.z)); + vec2 at = (vec2(p) + 0.5) / vec2(pc.w, pc.h); + return vec3((at * 2.0 - 1.0) * pc.proj.xy * z, z); +} + +void main() { + vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH); + ivec2 full = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1)); + if (fetchTex(pc.depth, full).r >= 1.0) { o_ao = vec4(1.0); return; } // sky + vec3 p = viewPos(uv); + // The surface normal from neighbouring depths (the smaller step on each axis, so edges hold). + vec2 texel = 1.0 / vec2(pc.outW, pc.outH); + vec3 px1 = viewPos(uv + vec2(texel.x, 0)) - p, px2 = p - viewPos(uv - vec2(texel.x, 0)); + vec3 py1 = viewPos(uv + vec2(0, texel.y)) - p, py2 = p - viewPos(uv - vec2(0, texel.y)); + // The smaller step, but never one off the screen's edge (there it clamps onto this pixel, + // a zero step, and the normal goes wrong: a dark line along the border). + bool right = uv.x + texel.x < 1.0, left = uv.x - texel.x > 0.0, down = uv.y + texel.y < 1.0, up = uv.y - texel.y > 0.0; + vec3 dx = !right ? px2 : !left ? px1 : dot(px1, px1) < dot(px2, px2) ? px1 : px2; + vec3 dy = !down ? py2 : !up ? py1 : dot(py1, py1) < dot(py2, py2) ? py1 : py2; + vec3 n = normalize(cross(dx, dy)); + if (dot(n, p) > 0.0) n = -n; // towards the camera + // Interleaved gradient noise turns the spiral per pixel; the blur evens it out. + float noise = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))); + const int kSamples = 12; + float occlusion = 0.0; + for (int i = 0; i < kSamples; ++i) { + float t = (float(i) + noise) / float(kSamples); + float a = (float(i) * 2.39996 + noise * 6.2831853); + float r = pc.radius * t; + vec2 off = vec2(cos(a), sin(a)) * r / (p.z * pc.proj.xy * 2.0); + vec2 q = uv + off; + if (any(lessThan(q, vec2(0.0))) || any(greaterThan(q, vec2(1.0)))) continue; // off screen: nothing known there + vec3 v = viewPos(q) - p; + float vv = dot(v, v); + float falloff = max(0.0, 1.0 - vv / (pc.radius * pc.radius)); + occlusion += max(0.0, dot(v, n) - 0.01 * p.z) / (vv + 1.0) * falloff; + } + float ao = max(0.0, 1.0 - 2.0 * occlusion / float(kSamples) * pc.radius * 0.6); + o_ao = vec4(mix(1.0, ao * ao, pc.strength)); +} diff --git a/engine/shaders/ssao_blur.frag b/engine/shaders/ssao_blur.frag new file mode 100644 index 0000000..1a3070b --- /dev/null +++ b/engine/shaders/ssao_blur.frag @@ -0,0 +1,34 @@ +#version 460 +// Smooths the occlusion (4×4, weighted by how close each sample's depth is to this pixel's). +#define KEEL_POST // screen-space: render targets, sampled clamped +#include "backend.glsl" +PUSH_BLOCK { + uint ao, depth; + uint w, h; // depth size + uint outW, outH; // this target's (= the occlusion's) + float pad0, pad1; + vec4 proj; +} pc; +layout(location = 0) out vec4 o_ao; + +float linearDepth(vec2 uv) { + ivec2 p = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1)); + float d = fetchTex(pc.depth, p).r; + return pc.proj.z * pc.proj.w / (pc.proj.w - d * (pc.proj.w - pc.proj.z)); +} + +void main() { + ivec2 o = ivec2(gl_FragCoord.xy); + vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH); + float z = linearDepth(uv); + float sum = 0.0, weight = 0.0; + for (int y = -2; y < 2; ++y) + for (int x = -2; x < 2; ++x) { + ivec2 q = clamp(o + ivec2(x, y), ivec2(0), ivec2(pc.outW - 1, pc.outH - 1)); + float zq = linearDepth((vec2(q) + 0.5) / vec2(pc.outW, pc.outH)); + float wgt = max(0.0, 1.0 - abs(zq - z) / (0.05 * z + 1.0)); + sum += fetchTex(pc.ao, q).r * wgt; + weight += wgt; + } + o_ao = vec4(weight > 0.0 ? sum / weight : 1.0); +} diff --git a/engine/shaders/ssr.frag b/engine/shaders/ssr.frag new file mode 100644 index 0000000..e0741dc --- /dev/null +++ b/engine/shaders/ssr.frag @@ -0,0 +1,82 @@ +#version 460 +// Screen-space reflections (half resolution): from each pixel, the view ray mirrored about its +// normal (the mesh pass's G-buffer) is marched through view space against the depth buffer; +// on a hit (refined by bisection) it takes this frame's lit scene there. Fades with roughness, +// distance, the screen's edges and rays towards the camera, so it blends into the sky and +// probe reflections the mesh pass already gave. TAA (or the tonemap) adds it to the scene. +// Rows are the targets' own (uv from gl_FragCoord), on both backends. +#define KEEL_POST // screen-space: render targets, sampled clamped +#include "backend.glsl" +PUSH_BLOCK { + uint depth, gbuf, scene; + uint w, h; // full size + uint outW, outH; // this target's + float strength; + vec4 proj; // tan(half fov x), tan(half fov y), near, far +} pc; +layout(location = 0) out vec4 o_ssr; + +float viewZ(vec2 uv) { + ivec2 p = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1)); + float d = fetchTex(pc.depth, p).r; + return d >= 1.0 ? 1e9 : pc.proj.z * pc.proj.w / (pc.proj.w - d * (pc.proj.w - pc.proj.z)); +} +vec3 viewPos(vec2 uv) { // across the screen from the centre of the pixel its depth came from (as ssao.frag) + float z = viewZ(uv); + ivec2 p = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1)); + vec2 at = (vec2(p) + 0.5) / vec2(pc.w, pc.h); + return vec3((at * 2.0 - 1.0) * pc.proj.xy * z, z); +} +vec2 toUv(vec3 v) { return v.xy / (v.z * pc.proj.xy) * 0.5 + 0.5; } +vec3 octDecode(vec2 e) { + e = e * 2.0 - 1.0; + vec3 n = vec3(e, 1.0 - abs(e.x) - abs(e.y)); + if (n.z < 0.0) n.xy = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0); + return normalize(n); +} + +void main() { + vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH); + o_ssr = vec4(0.0); + vec4 g = fetchTex(pc.gbuf, clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1))); + float rough = g.z, weight = g.w; + if (weight <= 0.01 || rough > 0.65) return; + vec3 p = viewPos(uv); + if (p.z > 1e8) return; + vec3 n = octDecode(g.xy); + vec3 v = normalize(p); + vec3 r = reflect(v, n); + if (r.z < -0.2) return; // back at the camera: nothing on screen to see there + // March: steps grow with distance; thickness grows with them. + float jitter = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))); + float t = 4.0 + 6.0 * jitter, stepLen = 6.0; + const int STEPS = 40; + vec2 hit = vec2(-1.0); + float hitT = 0.0; + for (int i = 0; i < STEPS; ++i) { + vec3 q = p + r * t; + if (q.z <= pc.proj.z) break; + vec2 quv = toUv(q); + if (any(lessThan(quv, vec2(0.0))) || any(greaterThan(quv, vec2(1.0)))) break; + float sceneZ = viewZ(quv); + float behind = q.z - sceneZ; + if (behind > 0.0 && behind < stepLen * 2.0 + 8.0) { + float lo = t - stepLen, hi = t; // refine between the last two steps + for (int k = 0; k < 5; ++k) { + float mid = (lo + hi) * 0.5; + vec3 m = p + r * mid; + if (m.z - viewZ(toUv(m)) > 0.0) hi = mid; else lo = mid; + } + hitT = hi; + hit = toUv(p + r * hi); + break; + } + t += stepLen; + stepLen *= 1.12; + } + if (hit.x < 0.0) return; + vec2 edge = smoothstep(vec2(0.0), vec2(0.12), hit) * smoothstep(vec2(0.0), vec2(0.12), 1.0 - hit); + float fade = edge.x * edge.y * (1.0 - smoothstep(0.3, 0.65, rough)) * (1.0 - smoothstep(1500.0, 3000.0, hitT)) * smoothstep(-0.2, 0.1, r.z + 0.1); + vec3 c = min(sampleTexLod(pc.scene, hit, 0.0).rgb, vec3(16.0)); + o_ssr = vec4(c * weight * fade * pc.strength, fade); +} diff --git a/engine/shaders/taa.frag b/engine/shaders/taa.frag new file mode 100644 index 0000000..d739aff --- /dev/null +++ b/engine/shaders/taa.frag @@ -0,0 +1,69 @@ +#version 460 +// Temporal anti-aliasing (as id Tech 6's TSSAA, in spirit): the scene is drawn a sub-pixel off +// each frame; each pixel finds where it was last frame (its depth, last frame's camera) and +// blends with what was there, after clamping that to the colours around it now (variance +// clipping in YCoCg) so moved things don't leave ghosts. Weighted by brightness so highlights +// don't flicker. Rows are the targets' own (uv from gl_FragCoord), on both backends. +#define KEEL_POST // screen-space: render targets, sampled clamped +#include "backend.glsl" +#include "upsample.glsl" +PUSH_BLOCK { + mat4 reproject; // this frame's clip space → last frame's + uint scene, history, depth; + uint w, h; + uint reset; // no usable history (first frame, resized, teleported) + uint ssr; // reflections to add first (half size), 0 = none + float pad1; +} pc; +layout(location = 0) out vec4 o_color; + +// The scene with the half-size reflections / volumetric light on it: depth-aware for the pixel +// itself (no lines along edges), a plain read for its neighbours (only the colour clamp uses them). +vec3 sceneAt(ivec2 q) { + vec3 c = fetchTex(pc.scene, q).rgb; + if (pc.ssr != 0u) c += sampleTexLod(pc.ssr, (vec2(q) + 0.5) / vec2(pc.w, pc.h), 0.0).rgb; + return c; +} +vec3 sceneAtCentre(ivec2 q) { + vec3 c = fetchTex(pc.scene, q).rgb; + if (pc.ssr != 0u) c += upsampleDepthAware(pc.ssr, pc.depth, q).rgb; + return c; +} + +vec3 toYCoCg(vec3 c) { return vec3(dot(c, vec3(0.25, 0.5, 0.25)), dot(c, vec3(0.5, 0.0, -0.5)), dot(c, vec3(-0.25, 0.5, -0.25))); } +vec3 fromYCoCg(vec3 c) { return vec3(c.x + c.y - c.z, c.x + c.z, c.x - c.y - c.z); } +float weight(vec3 c) { return 1.0 / (1.0 + dot(c, vec3(0.2126, 0.7152, 0.0722))); } + +void main() { + ivec2 p = ivec2(gl_FragCoord.xy); + ivec2 last = ivec2(pc.w - 1, pc.h - 1); + vec3 cur = sceneAtCentre(p).rgb; + if (pc.reset != 0u) { o_color = vec4(cur, 1.0); return; } + // The colours around this pixel now (mean ± deviation), and the nearest depth among them + // (so an edge's history comes from the thing in front). + vec3 m1 = vec3(0.0), m2 = vec3(0.0); + float nearest = 1.0; + ivec2 at = p; + for (int y = -1; y <= 1; ++y) + for (int x = -1; x <= 1; ++x) { + ivec2 q = clamp(p + ivec2(x, y), ivec2(0), last); + vec3 c = toYCoCg(sceneAt(q).rgb); + m1 += c; + m2 += c * c; + float d = fetchTex(pc.depth, q).r; + if (d < nearest) nearest = d, at = q; + } + vec3 mu = m1 / 9.0, sigma = sqrt(abs(m2 / 9.0 - mu * mu)); + vec3 lo = mu - 1.25 * sigma, hi = mu + 1.25 * sigma; + vec2 uv = (vec2(p) + 0.5) / vec2(pc.w, pc.h); + vec4 prev = pc.reproject * vec4(((vec2(at) + 0.5) / vec2(pc.w, pc.h)) * 2.0 - 1.0, nearest, 1.0); + vec2 puv = prev.xy / prev.w * 0.5 + 0.5 + (uv - (vec2(at) + 0.5) / vec2(pc.w, pc.h)); + if (any(lessThan(puv, vec2(0.0))) || any(greaterThan(puv, vec2(1.0)))) { o_color = vec4(cur, 1.0); return; } + vec3 hist = sampleTex(pc.history, puv).rgb; + hist = fromYCoCg(clamp(toYCoCg(hist), lo, hi)); + // Less history when things move a lot on screen (it smears), more when still. + float moved = length((puv - uv) * vec2(pc.w, pc.h)); + float keep = mix(0.92, 0.7, clamp(moved / 24.0, 0.0, 1.0)); + float wc = (1.0 - keep) * weight(cur), wh = keep * weight(hist); + o_color = vec4((cur * wc + hist * wh) / (wc + wh), 1.0); +} diff --git a/engine/shaders/tonemap.frag b/engine/shaders/tonemap.frag new file mode 100644 index 0000000..ff8eb2c --- /dev/null +++ b/engine/shaders/tonemap.frag @@ -0,0 +1,78 @@ +#version 460 +#define KEEL_POST // screen-space: render targets, sampled clamped +#include "backend.glsl" +#include "upsample.glsl" +// HDR scene → display: ambient occlusion, bloom, exposure, ACES filmic curve, a light grade +// (saturation, vignette), then sRGB encoding. +PUSH_BLOCK { + uint scene; + float exposure; + uint ao; // 0 = none + uint bloom; // 0 = none + float bloomStrength; + float saturation; + float vignette; + float sharpen; // after TAA: give back a little crispness (0 = none) + uint depth; // scene depth (1 = sky) + uint ssr; // reflections to add (when TAA didn't), 0 = none + uint adapt; // eye adaptation (1×1 exposure multiplier), 0 = none + uint pad2; + vec4 shafts; // sun shafts: the sun's uv, strength (0 = none) + vec4 sunColor; +} pc; + +// Sun shafts (screen-space light scattering, GPU Gems 3 ch. 13): march towards the sun's spot +// on screen, adding up how much open sky lies on the way; air lit by the sun glows there, +// fading with distance from the sun. Started at a per-pixel offset so the steps don't band. +vec3 sunShafts(vec2 uv) { + const int N = 32; + vec2 toSun = pc.shafts.xy - uv; + vec2 stepUv = toSun * (0.85 / float(N)); + float jitter = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))); + vec2 p = uv + stepUv * jitter; + vec3 lit = vec3(0.0); + float w = 1.0, total = 0.0; + for (int i = 0; i < N; ++i, p += stepUv) { + bool inside = all(greaterThanEqual(p, vec2(0.0))) && all(lessThanEqual(p, vec2(1.0))); + if (inside && sampleTexLod(pc.depth, p, 0.0).r >= 1.0) // open sky: its light, the sun's brightest + lit += max(sampleTexLod(pc.scene, p, 0.0).rgb - 1.0, vec3(0.0)) * w; + total += w; + w *= 0.97; + } + vec2 aspect = vec2(float(texSize(pc.scene).x) / float(texSize(pc.scene).y), 1.0); + float fall = exp(-length(toSun * aspect) * 1.5); + return min(lit / total, vec3(8.0)) * fall * pc.shafts.z; +} + +layout(location = 0) in vec2 v_uv; +layout(location = 0) out vec4 o_color; + +vec3 aces(vec3 x) { + return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); +} + +vec3 toSrgb(vec3 c) { + return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c)); +} + +void main() { + vec3 hdr = sampleTex(pc.scene, v_uv).rgb; + const ivec2 px = ivec2(v_uv * vec2(texSize(pc.depth))); + if (pc.ssr != 0u) hdr += upsampleDepthAware(pc.ssr, pc.depth, px).rgb; + if (pc.sharpen > 0.0) { // unsharp mask against the 4 neighbours, limited so edges don't ring + vec2 t = 1.0 / vec2(texSize(pc.scene)); + vec3 n = sampleTex(pc.scene, v_uv + vec2(t.x, 0)).rgb + sampleTex(pc.scene, v_uv - vec2(t.x, 0)).rgb + + sampleTex(pc.scene, v_uv + vec2(0, t.y)).rgb + sampleTex(pc.scene, v_uv - vec2(0, t.y)).rgb; + hdr = max(hdr + clamp((hdr - n * 0.25) * pc.sharpen, -0.1 * (hdr + 0.05), 0.1 * (hdr + 0.05)), 0.0); + } + if (pc.ao != 0u) hdr *= upsampleDepthAware(pc.ao, pc.depth, px).r; + if (pc.bloom != 0u) hdr = mix(hdr, sampleTex(pc.bloom, v_uv).rgb, pc.bloomStrength); + if (pc.shafts.z > 0.0) hdr += sunShafts(v_uv); + float exposure = pc.exposure * (pc.adapt != 0u ? sampleTexLod(pc.adapt, vec2(0.5), 0.0).r : 1.0); + vec3 c = aces(hdr * exposure); + float l = dot(c, vec3(0.2126, 0.7152, 0.0722)); + c = max(mix(vec3(l), c, pc.saturation), 0.0); + vec2 d = v_uv - 0.5; + c *= 1.0 - pc.vignette * dot(d, d) * 2.0; + o_color = vec4(toSrgb(c), 1.0); +} diff --git a/engine/shaders/upsample.glsl b/engine/shaders/upsample.glsl new file mode 100644 index 0000000..4df893c --- /dev/null +++ b/engine/shaders/upsample.glsl @@ -0,0 +1,28 @@ +// Depth-aware ("bilateral") upsampling of a half-size target (ambient occlusion, reflections, +// volumetric light): the four nearest texels, each weighted by how close its depth is to this +// pixel's, so what's behind an edge doesn't bleed onto it (a plain bilinear read leaves dark or +// bright lines along corners and silhouettes). +float linearDepth(float d) { + const float n = 2.0, f = 16384.0; // the camera's near and far planes (render/renderer.cpp) + return d >= 1.0 ? f : n * f / (f - d * (f - n)); +} +vec4 upsampleDepthAware(uint tex, uint depthTex, ivec2 px) { + ivec2 full = texSize(depthTex), half_ = texSize(tex); + vec2 hp = (vec2(px) + 0.5) * vec2(half_) / vec2(full) - 0.5; + ivec2 b = ivec2(floor(hp)); + vec2 f = hp - vec2(b); + float z = linearDepth(fetchTex(depthTex, clamp(px, ivec2(0), full - 1)).r); + vec4 sum = vec4(0.0); + float wsum = 0.0; + for (int k = 0; k < 4; ++k) { + ivec2 o = ivec2(k & 1, k >> 1); + ivec2 t = clamp(b + o, ivec2(0), half_ - 1); + ivec2 tf = clamp((t * full) / half_ + (full / half_) / 2, ivec2(0), full - 1); // the full-size texel it stands for + float zt = linearDepth(fetchTex(depthTex, tf).r); + float bil = (o.x == 1 ? f.x : 1.0 - f.x) * (o.y == 1 ? f.y : 1.0 - f.y); + float w = bil / (0.002 + abs(zt - z) / max(z, 1.0)); + sum += fetchTex(tex, t) * w; + wsum += w; + } + return wsum > 0.0 ? sum / wsum : fetchTex(tex, clamp(b, ivec2(0), half_ - 1)); +} diff --git a/engine/shaders/volumetric.frag b/engine/shaders/volumetric.frag new file mode 100644 index 0000000..670f3b9 --- /dev/null +++ b/engine/shaders/volumetric.frag @@ -0,0 +1,56 @@ +#version 460 +// Volumetric light (half resolution): the sun and the dynamic lights scattered towards the eye +// by the air along each view ray, where they reach it (12 jittered steps between the eye and the +// depth buffer; each takes the sun's shadow and, for every light in that spot's cluster, its +// falloff, cone and a one-tap shadow; Henyey-Greenstein phase), so window frames cut shafts out of +// the sun and lamps glow in the haze (like Source 2's volumetric fog). Once per pixel, whatever overdraw the scene had. Added onto the reflections +// target (TAA, or the tonemap, adds that to the scene). Rows: the targets' own, both backends. +#define KEEL_POST // screen-space: render targets, sampled clamped +#include "backend.glsl" +#define KEEL_FRAME_ONLY +#include "model.glsl" +PUSH_BLOCK { +#ifndef KEEL_GL + FrameData frame; +#endif + uint depth; + uint w, h, outW, outH; + float pad0, pad1, pad2; + vec4 proj; // tan(half fov x), tan(half fov y), near, far +} pc; +#ifndef KEEL_GL +#define FRAME pc.frame +#endif +layout(location = 0) out vec4 o_light; + +#include "airlight.glsl" + +void main() { + o_light = vec4(0.0); + if (FRAME.volumetric.x <= 0.0) return; + const bool sun = FRAME.sunColor.w > 0.5 && FRAME.volumetric.w > 0.5; + vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH); + ivec2 px = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1)); + float d = fetchTex(pc.depth, px).r; + float z = d >= 1.0 ? 1e9 : pc.proj.z * pc.proj.w / (pc.proj.w - d * (pc.proj.w - pc.proj.z)); + // The view ray in world space (view: x right, y down the screen, z forward). + vec3 fwd = FRAME.camFwd.xyz, right = normalize(cross(fwd, vec3(0.0, 0.0, 1.0))), up = cross(right, fwd); + vec2 s = (uv * 2.0 - 1.0) * pc.proj.xy; + vec3 rd = normalize(fwd + right * s.x - up * s.y); + float dist = z * length(vec3(s, 1.0)); + float len = min(dist, FRAME.volumetric.y); + float jitter = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))); + float lit = 0.0; + vec3 lamps = vec3(0.0); + const int N = 12; + for (int i = 0; i < N; ++i) { + vec3 w = FRAME.camPos.xyz + rd * len * ((float(i) + jitter) / float(N)); + if (sun) lit += sunlitAt(w); + lamps += lightsAt(w, rd, uv); + } + float scattered = 1.0 - exp(-len * FRAME.volumetric.z); + vec3 sunPart = sun ? FRAME.sunColor.rgb * (lit / float(N)) * phaseHG(dot(rd, FRAME.sunDir.xyz), 0.6) : vec3(0.0); + // Lamps: each step's light over the stretch it stands for (the air between them is lit too). + vec3 lampPart = lamps / float(N) * scattered * 7.0; + o_light = vec4((sunPart * scattered + lampPart) * FRAME.volumetric.x, 0.0); +} diff --git a/engine/ui/ui.cpp b/engine/ui/ui.cpp new file mode 100644 index 0000000..387c2d3 --- /dev/null +++ b/engine/ui/ui.cpp @@ -0,0 +1,176 @@ +#include "ui/ui.h" +#include "core/log.h" +#include "fs/vfs.h" + +#include + +#include +#include + +namespace keel::ui { +namespace { +ImFont* g_fonts[3] = {}; +float g_scale = 1.0f; + +ImFont* loadFont(const char* path, float size) { + auto data = fs::read(path); + if (!data) { + log::warn("Font {} not found, using the built-in one", path); + return nullptr; + } + void* owned = IM_ALLOC(data->size()); // the atlas frees it + std::memcpy(owned, data->data(), data->size()); + return ImGui::GetIO().Fonts->AddFontFromMemoryTTF(owned, int(data->size()), size); +} + +void theme() { + ImGuiStyle& s = ImGui::GetStyle(); + s = ImGuiStyle(); + s.WindowRounding = 2; + s.FrameRounding = 1; + s.GrabRounding = 1; + s.TabRounding = 1; + s.ScrollbarRounding = 1; + s.WindowBorderSize = 1; + s.FrameBorderSize = 1; + s.WindowPadding = ImVec2(14, 12); + s.FramePadding = ImVec2(8, 5); + s.ItemSpacing = ImVec2(10, 8); + s.WindowTitleAlign = ImVec2(0.0f, 0.5f); + ImVec4* c = s.Colors; + auto rgb = [](int r, int g, int b, float a = 1.0f) { return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, a); }; + // Source 2013 GameUI's charcoal panels, grey frames and near-white text, with a red + // where Source has its orange. + const ImVec4 red = rgb(kAccent[0], kAccent[1], kAccent[2]), redLight = rgb(245, 70, 76), redDark = rgb(150, 24, 32); + c[ImGuiCol_Text] = rgb(235, 230, 220); + c[ImGuiCol_TextDisabled] = rgb(140, 136, 128); + c[ImGuiCol_WindowBg] = rgb(40, 40, 38, 0.94f); + c[ImGuiCol_ChildBg] = rgb(30, 30, 29, 0.6f); + c[ImGuiCol_PopupBg] = rgb(36, 36, 34, 0.98f); + c[ImGuiCol_Border] = rgb(90, 88, 82, 0.8f); + c[ImGuiCol_FrameBg] = rgb(28, 28, 27); + c[ImGuiCol_FrameBgHovered] = rgb(50, 49, 46); + c[ImGuiCol_FrameBgActive] = rgb(60, 58, 54); + c[ImGuiCol_TitleBg] = rgb(52, 51, 48); + c[ImGuiCol_TitleBgActive] = rgb(70, 30, 32); + c[ImGuiCol_Button] = rgb(70, 68, 63); + c[ImGuiCol_ButtonHovered] = rgb(95, 92, 84); + c[ImGuiCol_ButtonActive] = red; + c[ImGuiCol_Header] = rgb(70, 68, 63); + c[ImGuiCol_HeaderHovered] = rgb(95, 92, 84); + c[ImGuiCol_HeaderActive] = red; + c[ImGuiCol_Tab] = rgb(55, 54, 50); + c[ImGuiCol_TabHovered] = rgb(95, 92, 84); + c[ImGuiCol_TabSelected] = redDark; + c[ImGuiCol_TabSelectedOverline] = red; + c[ImGuiCol_SliderGrab] = red; + c[ImGuiCol_SliderGrabActive] = redLight; + c[ImGuiCol_CheckMark] = redLight; + c[ImGuiCol_TextSelectedBg] = rgb(kAccent[0], kAccent[1], kAccent[2], 0.45f); + c[ImGuiCol_NavCursor] = red; + c[ImGuiCol_Separator] = rgb(90, 88, 82, 0.6f); + c[ImGuiCol_ScrollbarBg] = rgb(28, 28, 27, 0.6f); + c[ImGuiCol_ScrollbarGrab] = rgb(80, 78, 72); +} +} // namespace + +void init() { + ImGuiIO& io = ImGui::GetIO(); + g_fonts[0] = loadFont("fonts/DejaVuSans.ttf", 16.0f); + g_fonts[1] = loadFont("fonts/DejaVuSans-Bold.ttf", 16.0f); + g_fonts[2] = loadFont("fonts/DejaVuSans-Bold.ttf", 48.0f); + if (g_fonts[0]) io.FontDefault = g_fonts[0]; + theme(); +} + +uint32_t accent(float alpha, float brightness) { + return ImGui::ColorConvertFloat4ToU32(ImVec4(std::min(kAccent[0] / 255.0f * brightness, 1.0f), std::min(kAccent[1] / 255.0f * brightness, 1.0f), + std::min(kAccent[2] / 255.0f * brightness, 1.0f), alpha)); +} + +ImFont* font(Font f) { + ImFont* r = g_fonts[int(f)]; + return r ? r : ImGui::GetFont(); +} + +float scale() { return g_scale; } + +void beginFrame(float windowHeight) { + g_scale = std::clamp(windowHeight / 1080.0f, 0.75f, 2.5f); + ImGui::GetStyle().FontScaleMain = g_scale; +} + +namespace { +struct Popup { + std::string title, message; + std::vector buttons; +}; +std::vector g_popups; + +void finish(size_t button) { + if (g_popups.empty()) return; + Popup p = std::move(g_popups.front()); // the action may queue another popup + g_popups.erase(g_popups.begin()); + if (button < p.buttons.size() && p.buttons[button].action) p.buttons[button].action(); +} +} // namespace + +void popup(std::string title, std::string message, std::vector buttons) { + if (buttons.empty()) buttons.push_back({"OK", {}}); + g_popups.push_back({std::move(title), std::move(message), std::move(buttons)}); +} + +bool popupOpen() { return !g_popups.empty(); } + +void closePopup() { + if (!g_popups.empty()) finish(g_popups.front().buttons.size() - 1); +} + +void drawPopups() { + if (g_popups.empty()) return; + const Popup& p = g_popups.front(); + const ImGuiViewport* vp = ImGui::GetMainViewport(); + const float s = scale(); + // Dim everything behind it, and swallow clicks meant for it. + ImGui::SetNextWindowPos(vp->Pos); + ImGui::SetNextWindowSize(vp->Size); + // Keep the dim layer and then the popup on top — but never while a button is held: moving + // focus cancels the press, so OK would never click. + const bool refocus = !ImGui::IsAnyMouseDown(); + if (refocus) ImGui::SetNextWindowFocus(); + ImGui::PushStyleColor(ImGuiCol_WindowBg, IM_COL32(0, 0, 0, 120)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::Begin("##popupdim", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoMove); + ImGui::End(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowSize(ImVec2(460 * s, 0)); + if (refocus) ImGui::SetNextWindowFocus(); + ImGui::Begin("##popup", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize); + const ImVec2 pos = ImGui::GetWindowPos(); + ImGui::GetWindowDrawList()->AddRectFilled(pos, ImVec2(pos.x + ImGui::GetWindowWidth(), pos.y + 4 * s), accent()); + ImGui::Dummy(ImVec2(0, 4 * s)); + ImGui::PushFont(font(Font::Bold), 0.0f); + ImGui::TextUnformatted(p.title.c_str()); + ImGui::PopFont(); + ImGui::Separator(); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted(p.message.c_str()); + ImGui::PopTextWrapPos(); + ImGui::Dummy(ImVec2(0, 6 * s)); + // Buttons on the right; the first is the default (Enter). + float width = 0; + for (const PopupButton& b : p.buttons) width += ImGui::CalcTextSize(b.label.c_str()).x + 40 * s; + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowWidth() - width - ImGui::GetStyle().WindowPadding.x)); + size_t pressed = SIZE_MAX; + for (size_t i = 0; i < p.buttons.size(); ++i) { + if (i) ImGui::SameLine(); + if (ImGui::Button(p.buttons[i].label.c_str(), ImVec2(ImGui::CalcTextSize(p.buttons[i].label.c_str()).x + 32 * s, 0))) pressed = i; + } + if (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter)) pressed = 0; + ImGui::End(); + if (pressed != SIZE_MAX) finish(pressed); +} + +} // namespace keel::ui diff --git a/engine/ui/ui.h b/engine/ui/ui.h new file mode 100644 index 0000000..bff8636 --- /dev/null +++ b/engine/ui/ui.h @@ -0,0 +1,38 @@ +#pragma once +// Game UI look: fonts from the game folder and a Source-like theme (dark +// translucent panels, flat buttons, light text, red highlights) on top of Dear ImGui. +#include +#include +#include +#include + +struct ImFont; + +namespace keel::ui { + +enum class Font { Regular, Bold, Title }; + +// After the renderer (ImGui context) and the VFS are up. +void init(); +ImFont* font(Font f); +// Sizes in pixels; scaled with the window height so 1080p looks like Source at 1080p. +float scale(); +void beginFrame(float windowHeight); + +// The highlight colour: highlights, the logo. +inline constexpr uint8_t kAccent[3] = {227, 34, 45}; +uint32_t accent(float alpha = 1.0f, float brightness = 1.0f); // ImU32 + +// ── Popups: modal messages over everything (disconnects, errors, questions) ── +struct PopupButton { + std::string label; + std::function action; // may be empty: just closes +}; +// Queued; shown one at a time. No buttons = a single "OK". +void popup(std::string title, std::string message, std::vector buttons = {}); +bool popupOpen(); +void closePopup(); // Escape: like pressing the last button +// The engine calls this each frame after the game's UI. +void drawPopups(); + +} // namespace keel::ui diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..d565327 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +# Dev shell for NixOS: `nix-shell`, then `cmake --preset debug && cmake --build build/debug`. +{ pkgs ? import {} }: +pkgs.mkShell { + packages = with pkgs; [ + cmake ninja gcc pkg-config git + mold ccache + shaderc # glslc + sdl3 + vulkan-loader vulkan-validation-layers vulkan-tools + ]; + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath (with pkgs; [ vulkan-loader sdl3 ]); + VK_LAYER_PATH = "${pkgs.vulkan-validation-layers}/share/vulkan/explicit_layer.d"; +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..81959ff --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1,13 @@ +# Fry3DX: the app (app.cpp) on the engine, and its modelling core (mesh, scene) as a library the +# tests use too. +add_library(fry_core STATIC mesh.cpp tools.cpp cut.cpp uv.cpp image.cpp sticker.cpp shapes.cpp scene.cpp package.cpp export.cpp print.cpp boolean.cpp modify.cpp) +target_include_directories(fry_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(fry_core PUBLIC glm::glm libzstd_static) +target_compile_definitions(fry_core PUBLIC GLM_ENABLE_EXPERIMENTAL) + +add_executable(fry3dx app.cpp icons.cpp) +target_link_libraries(fry3dx PRIVATE fry_core keel stb) +add_dependencies(fry3dx content) +foreach(t fry_core fry3dx) + target_compile_options(${t} PRIVATE -Wall -Wextra -Wno-missing-field-initializers) +endforeach() diff --git a/src/app.cpp b/src/app.cpp new file mode 100644 index 0000000..839e743 --- /dev/null +++ b/src/app.cpp @@ -0,0 +1,5864 @@ +// Fry3DX — Frying 3D Extreme. A 3D modeller that works the way you'd expect: pick a tool from the +// toolbox, click and drag. The view in the middle is drawn by the engine's renderer: plain +// and clear while you model (Solid), or properly lit (Rendered). The panels are Dear ImGui. +// +// fry3dx [file.fry] +#include "icons.h" +#include "mesh.h" +#include "scene.h" +#include "shapes.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace fry; +namespace stdfs = std::filesystem; + +namespace { + +double now() { return double(SDL_GetTicks()) / 1000.0; } // (seconds; works before the UI exists) + +constexpr float kUnits = 39.37f; // the engine's units (inches) in a metre: the view renders in them +constexpr float kPi = 3.14159265358979f; + +enum class Mode { Object, Edit }; +enum class Tool { Select, Move, Rotate, Scale, Draw, Extrude, Inset, Bevel, LoopCut, Knife, Hole }; +enum class Tab { Model, Paint, Layout, Render, Print }; +enum class LayTool { Select, Move, Rotate, Scale }; +enum class PrintTool { None, Measure, LayFlat }; +enum class PaintTool { Brush, Eraser, Fill, Picker, Picture }; +enum class Elem { Vertex, Edge, Face }; +enum class View { Perspective, Front, Back, Left, Right, Top, Bottom }; +enum class Orient { Local, World, Normal }; // which way the gizmo's arrows point +enum class Pivot { Middle, Origin, Opposite, Bottom }; // what stays put when turning / sizing +enum class Shading { Solid, Rendered, Wire }; +enum class SelShape { Box, Brush, Lasso }; + +struct ToolInfo { + Tool tool; + Icon icon; + const char* name; + const char* key; + const char* hint; + bool objectMode, editMode; +}; +const ToolInfo kTools[] = { + {Tool::Select, Icon::Select, "Select", "Q", "Click to select; drag round things. Objects: Shift adds, Ctrl takes away. Shape: Ctrl adds/takes one, Shift+click a whole loop, Shift+Ctrl+click an edge ring.", true, true}, + {Tool::Move, Icon::Move, "Move", "W", "Drag an arrow to move along it, the square to move freely. Hold Ctrl to snap.", true, true}, + {Tool::Rotate, Icon::Rotate, "Rotate", "E", "Drag a ring to turn about it; the outer ring turns it as you see it. Ctrl: 15 degree steps.", true, true}, + {Tool::Scale, Icon::Scale, "Scale", "R", "Drag a side's handle to stretch it (the other side stays, or see Tool options); hold Shift to stretch both sides from the middle. Middle: all ways.", true, true}, + {Tool::Draw, Icon::Draw, "Draw shape", "D", "Click points round a shape (on the ground, or on any face of an object); click the first point (or press Enter) to finish. Esc cancels.", true, false}, + {Tool::Extrude, Icon::Extrude, "Extrude", "X", "Select faces, then drag the arrow to pull them out (along the way they face).", false, true}, + {Tool::Inset, Icon::Inset, "Inset", "I", "Select faces, then drag the handle to make a smaller face inside each.", false, true}, + {Tool::Bevel, Icon::Bevel, "Round edges", "B", "Select edges (or faces), then drag the round handle to round them off.", false, true}, + {Tool::LoopCut, Icon::LoopCut, "Loop cut", "C", "Point at an edge: the cut shows across. Click to cut there.", false, true}, + {Tool::Knife, Icon::Knife, "Knife", "K", "Click points along where to cut, or drag a straight line. Enter or double-click cuts. Cutting right through: Tool options.", false, true}, + {Tool::Hole, Icon::Hole, "Hole", "O", "Press on a face and drag out the size: round, rectangle, triangle or hexagon (nuts); all the way through, a pocket, or only its outline (a new face).", true, true}, +}; + +struct PaintToolInfo { + PaintTool tool; + Icon icon; + const char* name; + const char* key; + const char* hint; +}; +struct LayToolInfo { + LayTool tool; + Icon icon; + const char* name; + const char* key; + const char* hint; +}; +const LayToolInfo kLayTools[] = { + {LayTool::Select, Icon::Select, "Select", "Q", "Click frames (or corners) to pick them, Shift adds; drag round them to pick many. Clicking the model picks its frame too."}, + {LayTool::Move, Icon::Move, "Move", "W", "Drag frames (or corners) about the picture."}, + {LayTool::Rotate, Icon::Rotate, "Turn", "E", "Drag round to turn what's picked (Ctrl: 15 degree steps)."}, + {LayTool::Scale, Icon::Scale, "Size", "R", "Drag out or in to make what's picked bigger or smaller (Shift: only across or only up)."}, +}; +// Printers' build plates (mm, across x deep x high) and what's printed with. +struct PrinterInfo { + const char* name; + float x, y, z; +}; +const PrinterInfo kPrinters[] = { + {"Prusa MK4 / MK4S", 250, 210, 220}, {"Prusa Core One", 250, 220, 270}, {"Prusa MINI+", 180, 180, 180}, {"Bambu Lab X1 / P1 / P2", 256, 256, 256}, + {"Bambu Lab A1", 256, 256, 256}, {"Bambu Lab A1 mini", 180, 180, 180}, {"Creality Ender 3 (V2, V3)", 220, 220, 250}, {"Creality K1", 220, 220, 250}, + {"Elegoo Neptune 4", 225, 225, 265}, {"Anycubic Kobra 3", 250, 250, 260}, {"Your own size", 0, 0, 0}, +}; +struct MaterialInfo { + const char* name; + float density; // g/cm3 +}; +const MaterialInfo kMaterials[] = {{"PLA", 1.24f}, {"PETG", 1.27f}, {"ABS", 1.04f}, {"ASA", 1.07f}, {"TPU", 1.21f}, {"Nylon", 1.14f}, {"Resin", 1.12f}}; + +const PaintToolInfo kPaintTools[] = { + {PaintTool::Brush, Icon::PaintBrush, "Brush", "B", "Paint on the model or on the flat picture. Alt+click picks a colour; [ and ] change the size."}, + {PaintTool::Eraser, Icon::Eraser, "Eraser", "E", "Paints its plain colour back."}, + {PaintTool::Fill, Icon::Fill, "Fill", "G", "Fills the patch of about the same colour where you click."}, + {PaintTool::Picker, Icon::Picker, "Pick colour", "I", "Click to take a colour from the model or the picture."}, + {PaintTool::Picture, Icon::Picture, "Place a picture", "Ctrl+V", "Paste a picture (copied in any app) or add one from a file, then drag its corners to fit. Enter places it."}, +}; + +glm::vec3 forwardOf(float yaw, float pitch) { + const float y = glm::radians(yaw), p = glm::radians(pitch); + return {std::cos(p) * std::cos(y), std::cos(p) * std::sin(y), -std::sin(p)}; +} + +// The engine's view-projection (render/renderer.cpp), in metres. +glm::mat4 viewProjection(const glm::vec3& eye, float yaw, float pitch, float fov, float aspect, glm::vec2 shift) { + const glm::vec3 f = forwardOf(yaw, pitch); + glm::mat4 view = glm::lookAt(eye * kUnits, eye * kUnits + f, glm::vec3(0, 0, 1)); + glm::mat4 proj = glm::perspective(glm::radians(fov), aspect, 2.0f, 16384.0f); + proj[1][1] *= -1.0f; + proj[2][0] -= shift.x, proj[2][1] -= shift.y; + return proj * view * glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)); +} + +struct Ray { + glm::vec3 origin, dir; +}; + +// Möller–Trumbore; t along the ray, or nothing. +std::optional hitTriangle(const Ray& r, const glm::vec3& a, const glm::vec3& b, const glm::vec3& c) { + const glm::vec3 e1 = b - a, e2 = c - a, p = glm::cross(r.dir, e2); + const float det = glm::dot(e1, p); + if (std::abs(det) < 1e-12f) return std::nullopt; + const float inv = 1.0f / det; + const glm::vec3 s = r.origin - a; + const float u = glm::dot(s, p) * inv; + if (u < 0 || u > 1) return std::nullopt; + const glm::vec3 q = glm::cross(s, e1); + const float v = glm::dot(r.dir, q) * inv; + if (v < 0 || u + v > 1) return std::nullopt; + const float t = glm::dot(e2, q) * inv; + return t > 1e-6f ? std::optional(t) : std::nullopt; +} + +std::optional hitPlane(const Ray& r, const glm::vec3& point, const glm::vec3& normal) { + const float d = glm::dot(r.dir, normal); + if (std::abs(d) < 1e-8f) return std::nullopt; + const float t = glm::dot(point - r.origin, normal) / d; + return t > 0 ? std::optional(r.origin + r.dir * t) : std::nullopt; +} + +// Where along the line (from `p`, along unit `axis`) the ray passes closest. +float closestOnAxis(const Ray& r, const glm::vec3& p, const glm::vec3& axis) { + const glm::vec3 w = p - r.origin; + const float b = glm::dot(axis, r.dir), d = glm::dot(axis, w), e = glm::dot(r.dir, w); + const float den = 1.0f - b * b; + return den < 1e-6f ? 0.0f : (b * e - d) / den; +} + +float distToSegment(ImVec2 p, ImVec2 a, ImVec2 b) { + const float vx = b.x - a.x, vy = b.y - a.y, len = vx * vx + vy * vy; + const float t = len > 0 ? std::clamp(((p.x - a.x) * vx + (p.y - a.y) * vy) / len, 0.0f, 1.0f) : 0.0f; + const float dx = a.x + vx * t - p.x, dy = a.y + vy * t - p.y; + return std::sqrt(dx * dx + dy * dy); +} + +bool insidePolygon(ImVec2 p, const std::vector& poly) { + bool in = false; + for (size_t i = 0, j = poly.size() - 1; i < poly.size(); j = i++) + if ((poly[i].y > p.y) != (poly[j].y > p.y) && p.x < (poly[j].x - poly[i].x) * (p.y - poly[i].y) / (poly[j].y - poly[i].y) + poly[i].x) in = !in; + return in; +} + +float snapTo(float v, float step) { return step > 0 ? std::round(v / step) * step : v; } + +using Axes = std::array; +glm::vec3 inFrame(const Axes& a, const glm::vec3& p) { return {glm::dot(p, a[0]), glm::dot(p, a[1]), glm::dot(p, a[2])}; } +glm::vec3 fromFrame(const Axes& a, const glm::vec3& c) { return a[0] * c.x + a[1] * c.y + a[2] * c.z; } + +const ImU32 kAxisCol[3] = {IM_COL32(235, 70, 70, 255), IM_COL32(110, 210, 80, 255), IM_COL32(70, 130, 245, 255)}; +const ImU32 kHot = IM_COL32(255, 225, 90, 255); +constexpr ImU32 kAccent = IM_COL32(255, 120, 40, 255); +const ImVec4 kAccentText(1.0f, 0.6f, 0.3f, 1.0f); + +// An object as it'll be printed: where it stands, with its finish, checked (kept until it changes). +struct PrintInfo { + uint64_t key = ~0ull; + Mesh world; + PrintCheck check; + glm::vec3 lo{0.0f}, hi{0.0f}; + keel::ModelId overhang{}; + bool hasOverhang = false; +}; + +keel::Cvar fry_printer("fry_printer", "0", keel::CVAR_ARCHIVE, "Fry3DX: the 3D printer (a number in the Print tab's list)"); +keel::Cvar fry_bed_x("fry_bed_x", "220", keel::CVAR_ARCHIVE, "Fry3DX: your own printer's build plate across (mm)"); +keel::Cvar fry_bed_y("fry_bed_y", "220", keel::CVAR_ARCHIVE, "Fry3DX: your own printer's build plate deep (mm)"); +keel::Cvar fry_bed_z("fry_bed_z", "250", keel::CVAR_ARCHIVE, "Fry3DX: how high your own printer prints (mm)"); +keel::Cvar fry_autosave("fry_autosave", "2", keel::CVAR_ARCHIVE, "Fry3DX: autosave every this many minutes while there are changes (0: off)"); + +class App : public keel::Game { +public: + explicit App(std::string path) : startPath_(std::move(path)) {} + const char* name() const override { return "Fry3DX"; } // (its settings folder, and bin/Fry3DX/) + const char* title() const override { return "Fry3DX"; } + bool wantsCursor() const override { return true; } + bool dockingUi() const override { return true; } + + void init() override { + static App* self = this; + // Console commands: for scripts and tests (the ` key opens the console). + static keel::Cmd open("fry_open", [](const keel::Args& a) { if (a.size() > 1) self->openFile(a[1]); }, "Fry3DX: open a .fry file"); + static keel::Cmd save("fry_save", [](const keel::Args& a) { if (a.size() > 1) self->saveFile(a[1]); }, "Fry3DX: save as"); + static keel::Cmd exp("fry_export", [](const keel::Args& a) { if (a.size() > 1) self->exportFile(a[1]); }, "Fry3DX: export glTF"); + static keel::Cmd add("fry_add", [](const keel::Args& a) { if (a.size() > 1) self->addShape(a[1]); }, "Fry3DX: add a shape (cube, rounded_cube, table...)"); + static keel::Cmd view("fry_view", [](const keel::Args& a) { + static const char* names[] = {"persp", "front", "back", "left", "right", "top", "bottom"}; + for (int i = 0; i < 7; ++i) + if (a.size() > 1 && a[1] == names[i]) self->setView(View(i)); + }, "Fry3DX: fry_view persp|front|back|left|right|top|bottom"); + static keel::Cmd orbit("fry_orbit", [](const keel::Args& a) { + if (a.size() > 2) self->view_ = View::Perspective, self->fov_ = 50, self->yaw_ = std::stof(a[1]), self->pitch_ = std::stof(a[2]); + if (a.size() > 3) self->dist_ = std::stof(a[3]); + }, "Fry3DX: fry_orbit yaw pitch [distance]"); + static keel::Cmd sel("fry_select", [](const keel::Args& a) { + self->selected_.clear(); + for (const Object& o : self->scene_.objects) + if (a.size() > 1 && o.name == a[1]) self->selected_.insert(o.id), self->active_ = o.id; + }, "Fry3DX: select an object by name"); + static keel::Cmd place("fry_place", [](const keel::Args& a) { + if (a.size() < 4) return; + for (Object& o : self->scene_.objects) + if (self->selected_.count(o.id)) o.position = {std::stof(a[1]), std::stof(a[2]), std::stof(a[3])}, self->touched(o.id); + }, "Fry3DX: fry_place x y z (metres) for the selection"); + static keel::Cmd turn("fry_turn", [](const keel::Args& a) { + if (a.size() < 4) return; + for (Object& o : self->scene_.objects) + if (self->selected_.count(o.id)) o.rotation = fromEuler({std::stof(a[1]), std::stof(a[2]), std::stof(a[3])}), self->touched(o.id); + }, "Fry3DX: fry_turn x y z (degrees) for the selection"); + static keel::Cmd edit("fry_edit", [](const keel::Args& a) { + self->enterEdit(); + if (a.size() > 1) self->elem_ = a[1] == "vertex" ? Elem::Vertex : a[1] == "edge" ? Elem::Edge : Elem::Face; + if (a.size() > 2 && a[2] == "all") self->selectAllElements(); + if (a.size() > 2 && a[2] != "all") self->selFaces_ = {std::stoi(a[2])}; + }, "Fry3DX: fry_edit [vertex|edge|face] [all|face number]"); + static keel::Cmd tool("fry_tool", [](const keel::Args& a) { + for (const ToolInfo& t : kTools) + if (a.size() > 1 && SDL_strcasecmp(a[1].c_str(), t.name) == 0) self->setTool(t.tool); + }, "Fry3DX: pick a tool by name"); + static keel::Cmd shading("fry_shading", [](const keel::Args& a) { + if (a.size() > 1) self->shading_ = a[1] == "rendered" ? Shading::Rendered : a[1] == "wire" ? Shading::Wire : Shading::Solid; + }, "Fry3DX: fry_shading solid|rendered|wire"); + static keel::Cmd finish("fry_finish", [](const keel::Args& a) { + for (Object& o : self->scene_.objects) + if (self->selected_.count(o.id)) { + if (a.size() > 1) o.round = std::stof(a[1]); + if (a.size() > 2) o.roundSteps = std::stoi(a[2]); + if (a.size() > 3) o.smoothLevels = std::stoi(a[3]); + if (a.size() > 4) o.mirror = std::stoi(a[4]); + self->touched(o.id); + } + }, "Fry3DX: fry_finish round steps smooth mirror (for the selection)"); + static keel::Cmd bevel("fry_bevel", [](const keel::Args& a) { + if (a.size() > 1) self->bevelWidth_ = std::stof(a[1]); + if (a.size() > 2) self->bevelSteps_ = std::stoi(a[2]); + self->bevelNow(); + }, "Fry3DX: round the selected edges: fry_bevel width steps"); + static keel::Cmd cut("fry_cut", [](const keel::Args& a) { + if (Object* o = self->editing(); o && a.size() > 2) self->cutAt(*o, {std::stoi(a[1]), std::stoi(a[2])}); + }, "Fry3DX: loop cut through the edge between two points"); + static keel::Cmd pic("fry_picture", [](const keel::Args& a) { if (a.size() > 1) self->addPicture(a[1]); }, "Fry3DX: add a reference picture"); + static keel::Cmd frame("fry_frame", [](const keel::Args&) { self->frameAll(); }, "Fry3DX: frame everything"); + static keel::Cmd tab("fry_tab", [](const keel::Args& a) { + if (a.size() > 1) + self->setTab(a[1] == "paint" ? Tab::Paint : a[1] == "layout" ? Tab::Layout : a[1] == "render" ? Tab::Render : a[1] == "print" ? Tab::Print : Tab::Model); + }, "Fry3DX: fry_tab model|paint|render"); + // (points as parts of the view: 0..1 across, 0..1 down) + auto viewPoint = [](const std::string& x, const std::string& y) { + return ImVec2(self->viewMin_.x + std::stof(x) * (self->viewMax_.x - self->viewMin_.x), self->viewMin_.y + std::stof(y) * (self->viewMax_.y - self->viewMin_.y)); + }; + static auto vp = viewPoint; + static keel::Cmd knife("fry_knife", [](const keel::Args& a) { + if (a.size() < 5) return; + if (a.size() > 5) self->knifeThrough_ = a[5] != "0"; + if (a.size() > 6) self->knifeAfter_ = std::stoi(a[6]); + if (a.size() > 7) self->knifeFill_ = a[7] != "0"; + std::vector pts; + for (size_t i = 1; i + 1 < a.size() && i < 5; i += 2) pts.push_back(vp(a[i], a[i + 1])); + if (Object* o = self->editing()) self->knifeApply(o->id, pts); + }, "Fry3DX: fry_knife x0 y0 x1 y1 [through after fill] (view 0..1)"); + static keel::Cmd combineCmd("fry_combine", [](const keel::Args& a) { + const std::string w = a.size() > 1 ? a[1] : "add"; + self->combineSelected(w == "subtract" ? Combine::Subtract : w == "overlap" ? Combine::Overlap : Combine::Add); + }, "Fry3DX: fry_combine add|subtract|overlap (the picked, the last kept)"); + static keel::Cmd selectCmd("fry_selectall", [](const keel::Args&) { + self->selected_.clear(); + for (const Object& o : self->scene_.objects) self->selected_.insert(o.id), self->active_ = o.id; + }, "Fry3DX: pick every object (the first added last)"); + static keel::Cmd pickCmd("fry_pick", [](const keel::Args& a) { // fry_pick name...: those picked, the first the active one + self->selected_.clear(); + for (size_t i = a.size(); i-- > 1;) + for (const Object& o : self->scene_.objects) + if (o.name == a[i]) self->selected_.insert(o.id), self->active_ = o.id; + }, "Fry3DX: fry_pick name... (the first kept when combining)"); + static keel::Cmd copiesCmd("fry_copies", [](const keel::Args& a) { // fry_copies n row dx dy dz | n round angle [joined] + if (a.size() < 3) return; + auto& c = self->copies_; + c.count = std::stoi(a[1]), c.round = a[2] == "round"; + if (c.round && a.size() > 3) c.angle = std::stof(a[3]), c.centre = glm::vec3(0.0f); + if (!c.round && a.size() > 5) c.step = {std::stof(a[3]), std::stof(a[4]), std::stof(a[5])}; + c.joined = a.back() == "joined"; + self->makeCopies(); + }, "Fry3DX: fry_copies n row dx dy dz [joined] | n round angle [joined]"); + static keel::Cmd thickCmd("fry_thicken", [](const keel::Args& a) { + if (a.size() > 1) self->thickness_ = std::abs(std::stof(a[1])), self->thicknessOut_ = std::stof(a[1]) < 0; + self->thickenActive(); + }, "Fry3DX: fry_thicken metres (negative: outwards)"); + static keel::Cmd groupCmd("fry_group", [](const keel::Args& a) { + self->groupSelected(); + if (a.size() > 1 && !self->scene_.folders.empty()) self->scene_.folders.back().name = a[1], self->renamingFolder_ = 0; + }, "Fry3DX: fry_group [name]: the picked into a new folder"); + static keel::Cmd goCmd("fry_goto", [](const keel::Args&) { self->goTo(self->selected_); }, "Fry3DX: the camera to the picked"); + static keel::Cmd drawCmd("fry_draw", [](const keel::Args& a) { // fry_draw mode thick x y x y x y ... (view 0..1) + if (a.size() < 9) return; + self->setTool(Tool::Draw); + self->drawOnMode_ = std::stoi(a[1]), self->drawThickness_ = std::stof(a[2]); + self->drawPts_.clear(); + const bool snap = self->snap_; + self->snap_ = false; + for (size_t i = 3; i + 1 < a.size(); i += 2) { + ImGuiIO io = ImGui::GetIO(); + io.MousePos = ImVec2(self->viewMin_.x + std::stof(a[i]) * (self->viewMax_.x - self->viewMin_.x), self->viewMin_.y + std::stof(a[i + 1]) * (self->viewMax_.y - self->viewMin_.y)); + if (auto p = self->drawPoint(io)) self->drawPts_.push_back(*p); + } + self->snap_ = snap; + self->finishShape(); + }, "Fry3DX: fry_draw mode(0 new,1 add,2 cut) thickness x y x y x y..."); + static keel::Cmd extrudeCmd("fry_extrude", [](const keel::Args& a) { if (a.size() > 1) self->extrudeNow(std::stof(a[1])); }, "Fry3DX: extrude the picked faces this far (m)"); + static keel::Cmd holeCmd("fry_hole", [](const keel::Args& a) { + Object* o = self->editing() ? self->editing() : self->find(self->active_); + if (!o || a.size() < 3) return; + const int face = std::stoi(a[1]); + if (a.size() > 3) { + const float d = std::stof(a[3]); + self->holeMode_ = d < 0 ? 2 : d == 0 ? 0 : 1; + if (d > 0) self->holeDepth_ = d; + } + if (a.size() > 4) { + static const char* names[] = {"round", "rect", "triangle", "hex"}; + for (int i = 0; i < 4; ++i) + if (a[4] == names[i]) self->holeShape_ = i; + } + const float across = std::stof(a[2]); + if (a.size() > 5) self->holeTall_ = std::stof(a[5]), self->holeSize_ = across; + if (face >= 0 && face < int(o->mesh.faces.size())) self->applyHole(o->id, face, faceCenter(o->mesh, face), across); + }, "Fry3DX: fry_hole face across [depth: 0 through, -1 outline only] [round|rect|triangle|hex] [tall] (metres, the object's own)"); + static keel::Cmd color("fry_color", [](const keel::Args& a) { + if (a.size() > 3) self->paintColor_ = {std::stof(a[1]), std::stof(a[2]), std::stof(a[3]), 1.0f}; + }, "Fry3DX: fry_color r g b (painting, 0..1)"); + static keel::Cmd stroke("fry_stroke", [](const keel::Args& a) { + if (a.size() < 5) return; + const ImVec2 p0 = vp(a[1], a[2]), p1 = vp(a[3], a[4]); + for (int i = 0; i <= 40; ++i) self->paintAt(ImVec2(p0.x + (p1.x - p0.x) * float(i) / 40, p0.y + (p1.y - p0.y) * float(i) / 40), i == 0); + self->endStroke(); + }, "Fry3DX: fry_stroke x0 y0 x1 y1: a brush stroke across the view (0..1)"); + static keel::Cmd paste("fry_pastefile", [](const keel::Args& a) { if (a.size() > 1) self->floatFromFile(a[1]); }, "Fry3DX: place a picture file onto the selected object"); + static keel::Cmd corner("fry_corner", [](const keel::Args& a) { + if (PictureLayer* l = self->editLayer(); l && a.size() > 3) l->corners[size_t(std::clamp(std::stoi(a[1]), 0, 3))] = {std::stof(a[2]), std::stof(a[3])}; + }, "Fry3DX: fry_corner i x y: move a placed picture's corner (picture pixels)"); + static keel::Cmd placePic("fry_lock", [](const keel::Args&) { self->lockLayer(); }, "Fry3DX: lock the picture being moved (Enter)"); + static keel::Cmd flat("fry_pasteflat", [](const keel::Args& a) { if (a.size() > 1) self->pasteOnSurface_ = a[1] == "0"; }, "Fry3DX: fry_pasteflat 1: new pictures go on the flat picture"); + static keel::Cmd unlock("fry_unlock", [](const keel::Args& a) { + Object* o = self->find(self->active_); + if (!o || a.size() < 3 || !self->owner_.count(o->id) || !o->picture) return; + const int x = std::stoi(a[1]), y = std::stoi(a[2]); + if (x < 0 || y < 0 || x >= o->picture->w || y >= o->picture->h) return; + if (const int i = self->owner_[o->id][size_t(y) * size_t(o->picture->w) + size_t(x)]; i > 0) self->unlockLayer(o->id, i - 1); + }, "Fry3DX: fry_unlock x y: click the Picture panel there (picture pixels)"); + static keel::Cmd moveSticker("fry_sticker", [](const keel::Args& a) { + Object* o = self->find(self->ledit_.object); + PictureLayer* l = self->editLayer(); + if (!o || !l || a.size() < 3) return; + const Ray ray = self->mouseRay(vp(a[1], a[2])); + const Hit h = self->pickObject(ray, o->id, true); + if (!h.object) return self->say("fry_sticker: not on the object there"); + l->face = h.face, l->centre = glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(ray.origin + ray.dir * h.t, 1.0f)); + if (a.size() > 4) l->width = std::stof(a[3]), l->height = std::stof(a[4]); + settleSticker(o->mesh, *l); + }, "Fry3DX: fry_sticker x y [w h]: move the sticker being moved to that point of the view"); + static keel::Cmd capture("fry_capture", [](const keel::Args& a) { if (a.size() > 1) self->savePicture(a[1]); }, "Fry3DX: save a picture of the view"); + static keel::Cmd layout("fry_layout", [](const keel::Args& a) { + Object* o = self->find(self->active_); + if (!o || a.size() < 2) return; + if (a[1] == "start") self->readyToPaint(o->id); + if (a[1] == "all") self->layoutSelectAll(); + if (a[1] == "frame" && a.size() > 2) self->lay_.object = o->id, self->lay_.corners = false, self->lay_.frames = {std::stoi(a[2])}; + if (a[1] == "checker" && a.size() > 2) self->layChecker_ = a[2] == "1"; + if (a[1] == "keep" && a.size() > 2) self->layKeepPaint_ = a[2] == "1"; + if (a[1] == "again") self->layoutAgain(*o); + if (a[1] == "turn" && a.size() > 2) { + const float r = glm::radians(std::stof(a[2])); + self->layoutApply([r](glm::vec2 p, glm::vec2 m) { return m + glm::vec2(std::cos(r) * (p - m).x - std::sin(r) * (p - m).y, std::sin(r) * (p - m).x + std::cos(r) * (p - m).y); }); + } + if (a[1] == "move" && a.size() > 3) { + const glm::vec2 d(std::stof(a[2]), std::stof(a[3])); + self->layoutApply([d](glm::vec2 p, glm::vec2) { return p + d; }); + } + if (a[1] == "scale" && a.size() > 2) { + const float k = std::stof(a[2]); + self->layoutApply([k](glm::vec2 p, glm::vec2 m) { return m + (p - m) * k; }); + } + }, "Fry3DX: fry_layout start|all|frame N|turn deg|move x y|scale k|checker 0/1|keep 0/1|again"); + static keel::Cmd print("fry_print", [](const keel::Args& a) { + if (a.size() < 2) return; + if (a[1] == "bed") self->putOnBed(); + if (a[1] == "middle") self->middleOfBed(); + if (a[1] == "printer" && a.size() > 2) keel::execute("fry_printer " + a[2]); + if (a[1] == "size" && a.size() > 3) self->pushUndo(), self->printResize(std::stoi(a[2]), std::stof(a[3])); + if (a[1] == "measure" && a.size() > 5) { + self->printTool_ = PrintTool::Measure, self->measure_.clear(); + for (int i = 0; i < 2; ++i) + if (auto p = self->measurePoint(vp(a[size_t(2 + i * 2)], a[size_t(3 + i * 2)]))) self->measure_.push_back(*p); + } + if (a[1] == "flat" && a.size() > 3) { // (as clicking with Lay flat) + self->printTool_ = PrintTool::LayFlat; + ImGuiIO& io = ImGui::GetIO(); + const ImVec2 keep = io.MousePos; + io.MousePos = vp(a[2], a[3]); + const Hit h = self->pickObject(self->mouseRay(io.MousePos)); + io.MousePos = keep; + if (Object* o = h.object ? self->find(h.object) : nullptr) { + const glm::vec3 n = faceNormal(self->printInfo(*o).world, h.face); + self->pushUndo(); + o->rotation = glm::normalize(glm::rotation(n, glm::vec3(0, 0, -1)) * o->rotation), self->touched(o->id); + o->position.z -= self->printInfo(*o).lo.z, self->touched(o->id); + } + self->printTool_ = PrintTool::None; + } + }, "Fry3DX: fry_print bed|middle|printer N|size axis mm|measure x0 y0 x1 y1|flat x y"); + static keel::Cmd autosaveNow("fry_autosavenow", [](const keel::Args&) { self->autosave(true); }, "Fry3DX: autosave now"); + static keel::Cmd closeAsk("fry_close", [](const keel::Args&) { if (self->allowQuit()) keel::execute("quit"); }, "Fry3DX: as the window's close button"); + static keel::Cmd recover("fry_recover", [](const keel::Args&) { self->findAutosaves(), self->showRecover_ = !self->recover_.empty(); }, "Fry3DX: show the autosaves to recover"); + static keel::Cmd about("fry_about", [](const keel::Args&) { self->showAbout_ = true; }, "Fry3DX: show the About window"); + keel::execute("r_autoexposure 0; r_ssr 0; r_volumetric 0; r_shafts 0; r_dynres 0; r_renderscale 1; fps_max 144"); + newScene(); + if (!startPath_.empty()) openFile(startPath_); + findAutosaves(); + } + + void frame(double) override { autosave(); } + bool allowQuit() override { + if (!dirty_) return dropAutosave(), true; + askSave_ = 1; + return false; + } + void shutdown() override { + if (autosaveJob_.valid()) autosaveJob_.wait(); + } + void onEscape() override { + if (askSave_) askSave_ = 0; + else if (showRecover_) showRecover_ = false; + else if (editLayer()) cancelFloating(); + else if (!knifePts_.empty()) knifePts_.clear(); + else if (!drawPts_.empty()) drawPts_.clear(); + else if (drag_.active) cancelDrag(); + else if (mode_ == Mode::Edit) leaveEdit(); + else selected_.clear(); + } + + void render(keel::Renderer& r) override { + r_ = &r; + captureStep(); + setupUi(); + loadLogo(); + applyShading(); + takeDialogResult(); + ImGuiIO& io = ImGui::GetIO(); + const ImGuiID dock = ImGui::DockSpaceOverViewport(0, nullptr, ImGuiDockNodeFlags_PassthruCentralNode); + if (!layoutDone_) defaultLayout(dock); + if (ImGuiDockNode* c = ImGui::DockBuilderGetCentralNode(dock)) viewMin_ = c->Pos, viewMax_ = ImVec2(c->Pos.x + c->Size.x, c->Pos.y + c->Size.y); + updateCamera(io); + menuBar(); + toolbox(); + toolOptions(); + layersPanel(); + propertiesPanel(); + if (tab_ == Tab::Paint || tab_ == Tab::Layout) picturePanel(); + statusBar(); + if (!capturing()) viewButtons(); + if (showHelp_) helpWindow(); + if (showAbout_) aboutWindow(); + copiesWindow(); + thickenWindow(); + saveQuestion(); + recoverWindow(); + if (!askSave_ && !showRecover_) shortcuts(io); + viewport(io); + draw3D(r); + overlays(); + } + +private: + // ── The document ── + Scene scene_; + std::string startPath_, path_; + bool dirty_ = false; + struct Snapshot { + Scene scene; + std::set selected; + uint32_t active; + Mode mode; + std::set verts, faces; + std::set> edges; + }; + std::vector undo_, redo_; + + Mode mode_ = Mode::Object; + Tool tool_ = Tool::Move; + Elem elem_ = Elem::Face; + std::set selected_; + uint32_t active_ = 0; // the object last picked (edited in shape editing) + uint32_t activeRef_ = 0; // a reference picture picked in the Layers panel + std::set selVerts_, selFaces_; + std::set> selEdges_; + + // ── Tabs: modelling, painting pictures onto it, making a picture of it ── + Tab tab_ = Tab::Model; + + // ── The view ── + keel::Renderer* r_ = nullptr; + glm::vec2 shift_{0.0f}; // (the view's middle on the screen: it sits between the panels) + View view_ = View::Perspective; + glm::vec3 target_{0, 0, 0.4f}; + float yaw_ = -135.0f, pitch_ = 25.0f, dist_ = 4.0f, fov_ = 50.0f; + ImVec2 viewMin_{0, 0}, viewMax_{1, 1}; + glm::mat4 vp_{1.0f}; + glm::vec3 eye_{0.0f}; + bool orbiting_ = false, panning_ = false; + bool xray_ = false, showGrid_ = true, renderGrid_ = false; + std::string appliedLook_; + keel::ModelId ground_{}; + bool haveGround_ = false; + Shading shading_ = Shading::Solid; + int appliedShading_ = -1; + + // ── Tools ── + bool snap_ = false; + float snapMove_ = 0.05f, snapAngle_ = 15.0f, snapScale_ = 0.1f; + float drawThickness_ = 0.05f, extrudeAmount_ = 0.1f, insetAmount_ = 0.2f, mergeDistance_ = 0.001f; + float bevelWidth_ = 0.05f; + int bevelSteps_ = 3, cuts_ = 1; + Orient orientObject_ = Orient::Local, orientEdit_ = Orient::Normal; + Pivot rotatePivot_ = Pivot::Middle, scalePivot_ = Pivot::Opposite; + SelShape selShape_ = SelShape::Box; + float brushRadius_ = 30.0f; + std::vector drawPts_; + // Drawing on an object: the plane of the face the first point went on, and what's made with it + // (0 a new object standing on it, 1 added to it, 2 cut into it). + glm::vec3 drawOnP_{0.0f}, drawOnN_{0.0f, 0.0f, 1.0f}; + uint32_t drawOnto_ = 0; + int drawOnMode_ = 0; + struct Drag { + bool active = false; + int handle = -1; // 0-2 an axis, 3 free / even / about the view, 4-6 an axis's other end + Tool tool = Tool::Select; + Axes axes{}; + glm::vec3 pivot{0.0f}, anchor{0.0f}, handlePoint{0.0f}; + ImVec2 startMouse{0, 0}; + float startT = 0.0f, startDist = 0.0f, startMid = 0.0f; // (the handle's way from what stays put; from the middle) + glm::vec3 startHit{0.0f}; + Scene before; + std::set> bevelEdges; + } drag_; + bool boxing_ = false, painting_ = false, lassoing_ = false; + ImVec2 boxStart_{0, 0}; + std::vector lasso_; + std::pair cutHover_{-1, -1}; + // The knife: points clicked on the screen (or a straight drag). + std::vector knifePts_; + bool knifeDown_ = false; + ImVec2 knifePress_{0, 0}; + bool knifeThrough_ = false, knifeFill_ = true; + int knifeAfter_ = 1; // cut right through, then: 0 just the lines, 1 pieces of the one object, 2 objects of their own, 3 the smaller ones gone + // Holes: pressed on a face, dragged out to size. + // (sizes in metres where it stands: across = a round's width, a rectangle's width, a triangle's + // side, a hexagon's flats; tall: a rectangle's height) + float holeSize_ = 0.2f, holeTall_ = 0.1f, holeDepth_ = 0.1f, holeTurn_ = 0.0f, holeRoom_ = 0.0003f; + int holeShape_ = 0, holeSides_ = 32; // a HoleShape + int holeMode_ = 0; // 0 all the way through, 1 a pocket, 2 the outline only (a new face) + struct HoleDrag { + bool active = false; + uint32_t object = 0; + int face = -1; + glm::vec3 centre{0.0f}; // (the object's own space) + float radius = 0.0f; + ImVec2 press{0, 0}; + } hole_; + + // ── Painting ── + PaintTool paintTool_ = PaintTool::Brush; + glm::vec4 paintColor_{0.85f, 0.25f, 0.1f, 1.0f}; // a: how strong + float brushPx_ = 16.0f, hardness_ = 0.5f, fillTolerance_ = 0.15f; + int pictureSize_ = 1024; + bool showUvLines_ = true; + struct PaintStroke { + bool active = false, in2D = false, haveLast = false, waiting = false; // waiting: pressed off the object, starts when the brush gets to it + uint32_t object = 0; + std::shared_ptr image; // (the object's picture: a copy made for this stroke) + Stroke stroke; + glm::vec3 last{0.0f}; + glm::vec2 last2D{0.0f}; + } paint_; + // A picture layer unlocked to be moved: which, and what's being dragged (a corner 0-3, 4 the + // whole of it, 5 the turning handle) from where. + struct LayerEdit { + uint32_t object = 0; + int index = -1, drag = -1; + bool fresh = false; // (just pasted: Esc takes it away again) + glm::vec2 grab{0.0f}, offset{0.0f}; + std::array startCorners{}; + PictureLayer start; + } ledit_; + bool pasteOnSurface_ = true; + uint32_t renamingFolder_ = 0; + struct CopiesSetting { + int count = 3, axis = 2; + bool round = false, joined = false; + glm::vec3 step{0.3f, 0.0f, 0.0f}, centre{0.0f}; + float angle = 360.0f; + } copies_; + bool showCopies_ = false, showThicken_ = false, thicknessOut_ = false; + float thickness_ = 0.002f; + // The Layout tab: what's picked (frames, or corners as face and corner) and what's dragged. + struct LayoutState { + uint32_t object = 0; + bool corners = false; + std::set frames; + std::set> points; + int drag = 0; // 1 moving / turning / sizing, 2 a box + glm::vec2 grab{0.0f}, centre{0.0f}; + ImVec2 boxFrom{}; + std::vector> start; + std::shared_ptr startPicture; + double remapAt = 0; + } lay_; + LayTool layTool_ = LayTool::Move; + bool layKeepPaint_ = true, layChecker_ = false; + // The Print tab. + PrintTool printTool_ = PrintTool::None; + std::vector measure_; + bool measureSnap_ = true, showOverhangs_ = true, sizing_ = false; + float overhangDeg_ = 45.0f, infill_ = 15.0f; + int material_ = 0; + std::map printInfos_; + keel::ModelId bed_{}; + glm::vec3 bedFor_{0.0f}; + // Closing, New or Open with changes not saved: asked first (askSave_), then done; afterSave_: + // what to do once the Save it chose has finished (0 nothing, 1 quit, 2 new, 3 open). + int askSave_ = 0, afterSave_ = 0; + bool dialogCancelled_ = false; + // Autosave: a copy packed small in the background every fry_autosave minutes while there are + // changes, kept until saved or closed properly; offered back after a crash. + std::string autosavePath_; + uint64_t edits_ = 0, autosavedEdits_ = 0; + double lastAutosave_ = 0; + std::future autosaveJob_; + std::vector recover_; + bool showRecover_ = false; + std::map> owner_; // which layer shows at each pixel + struct Uploaded { + uint64_t stamp = 0; + double at = 0; + bool dirty = false; + }; + std::map uploaded_; + std::map paintTex_; + float picZoom_ = 0.0f; // the Picture panel: screen pixels a picture pixel, and where its corner is + ImVec2 picPan_{0, 0}; + uint32_t picFor_ = 0; + + // ── Saving a picture of the view (cropped to it, without the gizmos) ── + int captureFrames_ = 0; + std::string capturePath_; + + // ── GPU side ── + std::map> models_; // object id → its model, the state built + std::map versions_; + uint64_t versionClock_ = 1; + struct Shown { + uint64_t version = 0; + bool mirrorOnly = false; + Mesh mesh; + }; + std::map shown_; + struct Picture { + bool ok = false; + float aspect = 1.0f; + }; + std::map pictures_; + keel::TextureId logo_{}; + bool logoTried_ = false, haveLogo_ = false; + + // ── UI ── + bool styled_ = false, layoutDone_ = false, showHelp_ = false, showAbout_ = false; + std::string iniPath_, message_; + double messageAt_ = -100.0; + char renameBuf_[128] = ""; + uint32_t renaming_ = 0; + std::mutex dialogMutex_; + std::string dialogResult_; + int dialogKind_ = 0; // 1 open, 2 save, 3 export, 4 reference picture, 5 picture of the view, 6 a picture to paint on, 7 save the painted picture + int pendingKind_ = 0, dialogFilter_ = -1; + + // ───────────────────────────── document ───────────────────────────── + + Object* find(uint32_t id) { + for (Object& o : scene_.objects) + if (o.id == id) return &o; + return nullptr; + } + Reference* findRef(uint32_t id) { + for (Reference& r : scene_.references) + if (r.id == id) return &r; + return nullptr; + } + Object* editing() { return mode_ == Mode::Edit ? find(active_) : nullptr; } + void touched(uint32_t id) { versions_[id] = ++versionClock_, dirty_ = true, ++edits_; } + void touchAll() { + for (const Object& o : scene_.objects) versions_[o.id] = ++versionClock_; + } + // Its shape has been changed by hand: the library settings no longer apply. + void handMade(Object& o) { o.recipe.clear(), o.settings.clear(); } + void say(std::string text) { + message_ = std::move(text), messageAt_ = now(); + keel::log::info("{}", message_); + } + + void pushUndo() { + undo_.push_back({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_}); + if (undo_.size() > 200) undo_.erase(undo_.begin()); + redo_.clear(); + dirty_ = true, ++edits_; + } + void restore(Snapshot& s) { + scene_ = s.scene, selected_ = s.selected, active_ = s.active, mode_ = s.mode; + selVerts_ = s.verts, selFaces_ = s.faces, selEdges_ = s.edges; + if (mode_ == Mode::Edit && !find(active_)) mode_ = Mode::Object; + touchAll(); + drawPts_.clear(); + } + void undo() { + if (undo_.empty()) return say("Nothing to undo"); + redo_.push_back({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_}); + restore(undo_.back()); + undo_.pop_back(); + } + void redo() { + if (redo_.empty()) return say("Nothing to redo"); + undo_.push_back({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_}); + restore(redo_.back()); + redo_.pop_back(); + } + + void newScene() { + scene_ = {}; + path_.clear(); + undo_.clear(), redo_.clear(), selected_.clear(); + mode_ = Mode::Object, active_ = 0; + addShape("cube", false); + dirty_ = false; + target_ = {0, 0, 0.5f}, dist_ = 4.5f, view_ = View::Perspective, fov_ = 50, yaw_ = -135, pitch_ = 25; + } + Object& addObject(const std::string& base, Mesh mesh) { + Object o; + o.id = scene_.nextId++; + o.name = uniqueName(base); + o.mesh = std::move(mesh); + scene_.objects.push_back(std::move(o)); + touched(scene_.objects.back().id); + return scene_.objects.back(); + } + std::string uniqueName(const std::string& base) { + auto taken = [&](const std::string& n) { return std::any_of(scene_.objects.begin(), scene_.objects.end(), [&](const Object& o) { return o.name == n; }); }; + if (!taken(base)) return base; + for (int i = 2;; ++i) + if (!taken(std::format("{} {}", base, i))) return std::format("{} {}", base, i); + } + + // A shape from the library, standing on the ground under the middle of the view. + void addShape(const std::string& id, bool undoable = true) { + const ShapeRecipe* r = findRecipe(id); + if (!r) return say("No shape called " + id); + if (undoable) pushUndo(); + if (mode_ == Mode::Edit) leaveEdit(); + Object& o = addObject(r->name, makeFromRecipe(*r, defaultSettings(*r))); + o.recipe = r->id, o.settings = defaultSettings(*r), o.smooth = r->smooth; + o.position = glm::vec3(std::round(target_.x * 10) / 10, std::round(target_.y * 10) / 10, 0.0f); + if (view_ == View::Front || view_ == View::Back) o.position.x = 0; + if (view_ == View::Left || view_ == View::Right) o.position.y = 0; + selected_ = {o.id}, active_ = o.id; + if (undoable) say(std::format("{} added: its sizes are in Properties", r->name)); + } + + void deleteSelected() { + if (mode_ == Mode::Edit) return deleteElements(); + if (activeRef_ && selected_.empty()) { + pushUndo(); + std::erase_if(scene_.references, [&](const Reference& r) { return r.id == activeRef_; }); + activeRef_ = 0; + return; + } + if (selected_.empty()) return; + pushUndo(); + std::erase_if(scene_.objects, [&](const Object& o) { return selected_.count(o.id) && !o.locked; }); + selected_.clear(); + } + + void duplicateSelected() { + if (mode_ != Mode::Object || selected_.empty()) return; + pushUndo(); + std::set made; + const std::vector copy = scene_.objects; + for (const Object& o : copy) + if (selected_.count(o.id)) { + Object& n = addObject(o.name, o.mesh); + const uint32_t id = n.id; + const std::string name = n.name; + n = o, n.id = id, n.name = name, n.locked = false; + made.insert(id); + active_ = id; + } + selected_ = made; + touchAll(); + setTool(Tool::Move); + say("Duplicated: drag the arrows to move the copy"); + } + + // The selected objects become one (the last picked keeps its place, look and name). + void joinSelected() { + Object* into = find(active_); + if (mode_ != Mode::Object || selected_.size() < 2 || !into) return say("Select two or more objects to join (the last picked keeps its name)"); + pushUndo(); + const glm::mat4 toLocal = glm::inverse(objectMatrix(*into)); + for (const Object& o : scene_.objects) + if (selected_.count(o.id) && o.id != into->id) append(into->mesh, o.hasFinish() ? finishedMesh(o) : o.mesh, toLocal * objectMatrix(o)); + handMade(*into); + const uint32_t keep = into->id; + std::erase_if(scene_.objects, [&](const Object& o) { return selected_.count(o.id) && o.id != keep; }); + selected_ = {keep}, active_ = keep; + touched(keep); + } + + // The selected faces become an object of their own. + void separateSelected() { + Object* o = editing(); + if (!o || elem_ != Elem::Face || selFaces_.empty()) return say("Select faces to make into their own object"); + pushUndo(); + Mesh part; + part.verts = o->mesh.verts; + for (int f : selFaces_) part.faces.push_back(o->mesh.faces[size_t(f)]); + removeUnused(part); + deleteFaces(o->mesh, selFaces_); + removeUnused(o->mesh); + handMade(*o); + touched(o->id); + Object copy = *o; + Object& n = addObject(o->name + " part", std::move(part)); + const uint32_t id = n.id; + const std::string name = n.name; + Mesh m = std::move(n.mesh); + n = copy, n.id = id, n.name = name, n.mesh = std::move(m); + clearElements(); + leaveEdit(); + selected_ = {id}, active_ = id; + say("Separated into " + name); + } + + // ───────────────────────────── files ───────────────────────────── + + void openFile(const std::string& path) { + std::string err; + auto s = loadPackage(path, &err); + if (!s) return say("Couldn't open: " + err); + dropAutosave(); + scene_ = std::move(*s); + // (an old plain-text .fry: its pictures are PNGs beside it) + const stdfs::path here = stdfs::path(path).parent_path(); + for (Object& o : scene_.objects) { + if (!o.pictureFile.empty()) { + Image im; + if (loadImageFile((here / o.pictureFile).string(), im)) o.picture = std::make_shared(std::move(im)); + else say("Couldn't load the painted picture " + o.pictureFile); + } + for (PictureLayer& l : o.layers) { + Image im; + if (!l.image && !l.file.empty() && loadImageFile((here / l.file).string(), im)) l.image = std::make_shared(std::move(im)); + } + std::erase_if(o.layers, [](const PictureLayer& l) { return !l.image; }); + } + path_ = path; + undo_.clear(), redo_.clear(), selected_.clear(); + mode_ = Mode::Object, active_ = 0, dirty_ = false, ledit_ = {}; + touchAll(); + frameAll(); + say("Opened " + stdfs::path(path).filename().string()); + } + bool saveFile(std::string path) { + if (path.empty()) return false; + if (stdfs::path(path).extension() != ".fry") path += ".fry"; + std::string err; + if (!savePackage(path, scene_, 12, &err)) return afterSave_ = 0, say("Couldn't save: " + err), false; + dropAutosave(); + path_ = path, dirty_ = false; + say(std::format("Saved {} ({})", stdfs::path(path).filename().string(), sizeText(stdfs::file_size(path)))); + if (const int then = std::exchange(afterSave_, 0)) doAfterAsking(then); + return true; + } + static std::string sizeText(uintmax_t n) { + return n < 10240 ? std::format("{} bytes", n) : n < 10 << 20 ? std::format("{} KB", n >> 10) : std::format("{:.1f} MB", double(n) / 1048576.0); + } + static std::vector pngBytes(const Image& im) { + std::vector out; + stbi_write_png_to_func([](void* ctx, void* data, int n) { auto* v = static_cast*>(ctx); v->insert(v->end(), (uint8_t*)data, (uint8_t*)data + n); }, + &out, im.w, im.h, 4, im.px.data(), im.w * 4); + return out; + } + void exportFile(std::string path, int filter = -1) { + if (path.empty()) return; + std::string ext = stdfs::path(path).extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return char(std::tolower(c)); }); + const auto& formats = exportFormats(); + if (std::none_of(formats.begin(), formats.end(), [&](const auto& f) { return f.first == ext; })) + path += formats[size_t(filter >= 0 && filter < int(formats.size()) ? filter : 0)].first; + std::vector> pngs(scene_.objects.size()); + for (size_t i = 0; i < scene_.objects.size(); ++i) + if (const Object& o = scene_.objects[i]; o.picture && o.uvsFit()) pngs[i] = pngBytes(o.layers.empty() ? *o.picture : composePicture(o)); + std::string err; + if (!exportModel(path, scene_, pngs, &err)) return say("Couldn't export: " + err); + say("Exported " + stdfs::path(path).filename().string()); + } + + // ── Closing with changes, autosave ── + + // New, Open, Quit: asks about saving first if there are changes. + void whenSaved(int action) { + if (dirty_) askSave_ = action; + else doAfterAsking(action); + } + void doAfterAsking(int action) { + if (action == 1) dropAutosave(), keel::execute("quit"); + else if (action == 2) newScene(); + else if (action == 3) dialog(1); + } + void saveQuestion() { + if (!askSave_) return; + if (!ImGui::IsPopupOpen("Save changes?")) ImGui::OpenPopup("Save changes?"); + const ImVec2 c = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(c, ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + if (!ImGui::BeginPopupModal("Save changes?", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) return; + const std::string name = path_.empty() ? "Untitled" : stdfs::path(path_).filename().string(); + ImGui::Text("%s has changes that aren't saved.", name.c_str()); + ImGui::TextDisabled(askSave_ == 1 ? "Save them before closing?" : askSave_ == 2 ? "Save them before starting a new one?" : "Save them before opening another?"); + ImGui::Spacing(); + const float bw = 150.0f; + const int action = askSave_; + ImGui::PushStyleColor(ImGuiCol_Button, kAccent); + const bool save = ImGui::Button("Save", ImVec2(bw, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter); + ImGui::PopStyleColor(); + ImGui::SameLine(); + const bool drop = ImGui::Button(action == 1 ? "Don't save, close" : "Don't save", ImVec2(bw, 0)); + ImGui::SameLine(); + const bool cancel = ImGui::Button("Cancel", ImVec2(bw, 0)); + if (save || drop || cancel || !askSave_) askSave_ = 0, ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (save) { // (saveFile goes on with it once saved) + afterSave_ = action; + path_.empty() ? dialog(2) : void(saveFile(path_)); + } else if (drop) { + dropAutosave(); + dirty_ = false; + doAfterAsking(action); + } + } + std::string autosaveDir() const { return keel::prefPath() + "autosave/"; } + // Every fry_autosave minutes (when there are changes and nothing's being dragged): a copy + // packed as small as it goes, in the background. + void autosave(bool force = false) { + const float minutes = fry_autosave.f(); + if (!force && (minutes <= 0 || keel::isTestMode())) return; + if (!dirty_ || (!force && (edits_ == autosavedEdits_ || now() - lastAutosave_ < minutes * 60.0))) return; + if (!force && (ImGui::IsMouseDown(ImGuiMouseButton_Left) || drag_.active || paint_.active)) return; + if (autosaveJob_.valid() && autosaveJob_.wait_for(std::chrono::seconds(0)) != std::future_status::ready) return; + if (autosaveJob_.valid() && !autosaveJob_.get()) say("Couldn't autosave (is the disk full?)"); + std::error_code ec; + stdfs::create_directories(autosaveDir(), ec); + const std::string stem = path_.empty() ? "Untitled" : stdfs::path(path_).stem().string(); + if (autosavePath_.empty()) autosavePath_ = autosaveDir() + std::format("{}-{}.fry", stem, std::time(nullptr)); + std::ofstream(autosavePath_ + ".from") << path_; // (where it belongs) + lastAutosave_ = now(), autosavedEdits_ = edits_; + autosaveJob_ = std::async(std::launch::async, [copy = scene_, to = autosavePath_] { return savePackage(to, copy, 19); }); + } + // Its autosave isn't needed (saved, or closed on purpose). + void dropAutosave() { + if (autosaveJob_.valid()) autosaveJob_.wait(); + if (autosavePath_.empty()) return; + std::error_code ec; + stdfs::remove(autosavePath_, ec), stdfs::remove(autosavePath_ + ".from", ec); + autosavePath_.clear(), lastAutosave_ = now(); + } + // Autosaves left behind (it didn't close properly): offered back. + void findAutosaves() { + recover_.clear(); + std::error_code ec; + for (const auto& e : stdfs::directory_iterator(autosaveDir(), ec)) + if (e.path().extension() == ".fry") recover_.push_back(e.path().string()); + std::sort(recover_.begin(), recover_.end()); + showRecover_ = !recover_.empty() && !keel::isTestMode(); + } + void recoverWindow() { + if (!showRecover_) return; + if (!ImGui::IsPopupOpen("Recover your work?")) ImGui::OpenPopup("Recover your work?"); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + if (!ImGui::BeginPopupModal("Recover your work?", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) return; + ImGui::TextUnformatted("Fry3DX didn't close properly last time. These were autosaved:"); + ImGui::Spacing(); + std::string open; + for (const std::string& f : recover_) { + std::ifstream in(f + ".from"); + std::string from; + std::getline(in, from); + std::error_code ec; + const auto when = std::chrono::clock_cast(stdfs::last_write_time(f, ec)); + const std::string label = std::format("{} {:%Y-%m-%d %H:%M} {}", from.empty() ? "Untitled" : stdfs::path(from).filename().string(), + std::chrono::zoned_time(std::chrono::current_zone(), std::chrono::floor(when)), sizeText(stdfs::file_size(f, ec))); + ImGui::PushID(f.c_str()); + if (ImGui::Button("Open", ImVec2(70, 0))) open = f; + ImGui::SameLine(); + ImGui::TextUnformatted(label.c_str()); + ImGui::PopID(); + } + ImGui::Spacing(); + if (ImGui::Button("Throw them all away", ImVec2(200, 0))) { + std::error_code ec; + for (const std::string& f : recover_) stdfs::remove(f, ec), stdfs::remove(f + ".from", ec); + recover_.clear(); + } + ImGui::SameLine(); + if (ImGui::Button("Later", ImVec2(120, 0))) showRecover_ = false; + if (recover_.empty() || !showRecover_ || !open.empty()) showRecover_ = false, ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (!open.empty()) { + std::ifstream in(open + ".from"); + std::string from; + std::getline(in, from); + openFile(open); + path_ = from, dirty_ = true, autosavePath_ = open; // (it goes on autosaving there until saved) + say("Recovered" + (from.empty() ? std::string() : " " + stdfs::path(from).filename().string()) + ": save it to keep it (Ctrl+S)"); + } + } + + // A picture of the 3D view only (no panels, gizmos or outlines): the screen is saved a few + // frames on, with those hidden, then cut down to the view. + void savePicture(std::string path) { + if (path.empty()) return; + if (stdfs::path(path).extension() != ".png") path += ".png"; + capturePath_ = path, captureFrames_ = 5; + } + bool capturing() const { return captureFrames_ > 0; } + void captureStep() { + if (!captureFrames_) return; + const std::string tmp = keel::prefPath() + "fry_capture.png"; + if (--captureFrames_ == 3) keel::execute("screenshot \"" + tmp + "\""); + if (captureFrames_) return; + keel::waitForScreenshots(); + int w = 0, h = 0, n = 0; + stbi_uc* px = stbi_load(tmp.c_str(), &w, &h, &n, 4); + if (!px) return say("Couldn't save the picture"); + const ImVec2 size = ImGui::GetIO().DisplaySize; + const float sx = float(w) / std::max(size.x, 1.0f), sy = float(h) / std::max(size.y, 1.0f); + const int x0 = std::clamp(int(viewMin_.x * sx), 0, w - 1), y0 = std::clamp(int(viewMin_.y * sy), 0, h - 1); + const int x1 = std::clamp(int(viewMax_.x * sx), x0 + 1, w), y1 = std::clamp(int(viewMax_.y * sy), y0 + 1, h); + const bool ok = stbi_write_png(capturePath_.c_str(), x1 - x0, y1 - y0, 4, px + (size_t(y0) * size_t(w) + size_t(x0)) * 4, w * 4); + stbi_image_free(px); + std::error_code ec; + stdfs::remove(tmp, ec); + say(ok ? "Saved a picture of the view: " + stdfs::path(capturePath_).filename().string() : "Couldn't write " + capturePath_); + } + static bool loadImageFile(const std::string& path, Image& out) { + int w = 0, h = 0, n = 0; + stbi_uc* px = stbi_load(path.c_str(), &w, &h, &n, 4); + if (!px) return false; + out.w = w, out.h = h, out.px.assign(px, px + size_t(w) * size_t(h) * 4); + stbi_image_free(px); + return true; + } + static bool writePng(const std::string& path, const Image& im) { return stbi_write_png(path.c_str(), im.w, im.h, 4, im.px.data(), im.w * 4) != 0; } + void addPicture(const std::string& path) { + pushUndo(); + Reference ref; + ref.id = scene_.nextId++; + ref.path = path; + ref.axis = view_ == View::Front || view_ == View::Back ? Axis::Front : view_ == View::Top || view_ == View::Bottom ? Axis::Top : Axis::Side; + ref.width = 2.0f; + scene_.references.push_back(ref); + activeRef_ = ref.id; + if (view_ == View::Perspective) setView(ref.axis == Axis::Front ? View::Front : ref.axis == Axis::Top ? View::Top : View::Left); + say("Picture added: set its width in Properties to the real size"); + } + + // SDL's own file dialogs (the desktop's): the answer comes back later, maybe on another thread. + void dialog(int kind) { + static const SDL_DialogFileFilter fry[] = {{"Fry3DX files", "fry"}}, pics[] = {{"Pictures", "png;jpg;jpeg;bmp;tga"}}, png[] = {{"PNG picture", "png"}}; + static std::vector names; + static std::vector models; + if (models.empty()) { + for (const auto& [ext, what] : exportFormats()) names.push_back(what + " (" + ext + ")"), names.push_back(ext.substr(1)); + for (size_t i = 0; i < names.size(); i += 2) models.push_back({names[i].c_str(), names[i + 1].c_str()}); + } + pendingKind_ = kind; + auto done = [](void* user, const char* const* files, int filter) { + App* self = static_cast(user); + std::lock_guard lock(self->dialogMutex_); + if (files && files[0]) self->dialogResult_ = files[0], self->dialogKind_ = self->pendingKind_, self->dialogFilter_ = filter; + else self->dialogCancelled_ = true; + }; + SDL_Window* w = SDL_GetKeyboardFocus(); + const std::string here = path_.empty() ? std::string() : stdfs::path(path_).parent_path().string(); + const char* at = here.empty() ? nullptr : here.c_str(); + if (kind == 1) SDL_ShowOpenFileDialog(done, this, w, fry, 1, at, false); + else if (kind == 2) SDL_ShowSaveFileDialog(done, this, w, fry, 1, path_.empty() ? at : path_.c_str()); + else if (kind == 3) SDL_ShowSaveFileDialog(done, this, w, models.data(), int(models.size()), at); + else if (kind == 8 || kind == 9) { + static const SDL_DialogFileFilter mf[] = {{"3MF for slicers", "3mf"}}, stl[] = {{"STL", "stl"}}; + SDL_ShowSaveFileDialog(done, this, w, kind == 8 ? mf : stl, 1, at); + } + else if (kind == 5 || kind == 7) SDL_ShowSaveFileDialog(done, this, w, png, 1, at); + else SDL_ShowOpenFileDialog(done, this, w, pics, 1, at, false); + } + void takeDialogResult() { + std::string file; + int kind, filter; + { + std::lock_guard lock(dialogMutex_); + file.swap(dialogResult_), kind = dialogKind_, filter = dialogFilter_; + if (std::exchange(dialogCancelled_, false)) afterSave_ = 0; // (Save, then quit: but the saving was cancelled) + } + if (file.empty()) return; + if (kind == 1) openFile(file); + else if (kind == 2) saveFile(file); + else if (kind == 3) exportFile(file, filter); + else if (kind == 8 || kind == 9) { + if (stdfs::path(file).extension() != (kind == 8 ? ".3mf" : ".stl")) file += kind == 8 ? ".3mf" : ".stl"; + exportFile(file); + } + else if (kind == 4) addPicture(file); + else if (kind == 5) savePicture(file); + else if (kind == 6) floatFromFile(file); + else if (kind == 7) { + if (stdfs::path(file).extension() != ".png") file += ".png"; + if (Object* o = find(active_); o && o->picture) say(writePng(file, composePicture(*o)) ? "Saved " + stdfs::path(file).filename().string() : "Couldn't write " + file); + } + } + + // ───────────────────────────── modes and tools ───────────────────────────── + + void setTool(Tool t) { + for (const ToolInfo& i : kTools) + if (i.tool == t && ((mode_ == Mode::Object && !i.objectMode) || (mode_ == Mode::Edit && !i.editMode))) { + if (mode_ == Mode::Object && i.editMode) { // (a shape tool: go and edit the shape) + enterEdit(); + if (mode_ != Mode::Edit) return; + break; + } + return say(std::format("{} works on whole objects (Tab leaves shape editing)", i.name)); + } + tool_ = t; + drawPts_.clear(), knifePts_.clear(); + if ((t == Tool::Extrude || t == Tool::Inset) && elem_ != Elem::Face) elem_ = Elem::Face, clearElements(); + } + void enterEdit() { + if (mode_ == Mode::Edit) return; + Object* o = find(active_); + if (!o || !selected_.count(active_)) { + if (selected_.empty()) return say("Select an object first, then edit its shape"); + active_ = *selected_.begin(); + o = find(active_); + } + if (!o || o->locked) return say("That object is locked"); + mode_ = Mode::Edit; + selected_ = {active_}; + clearElements(); + if (tool_ == Tool::Draw) tool_ = Tool::Move; + drawPts_.clear(); + touched(o->id); // (shown without its rounding and smoothing while you edit) + } + void leaveEdit() { + if (mode_ != Mode::Edit) return; + mode_ = Mode::Object; + if (tool_ == Tool::Extrude || tool_ == Tool::Inset || tool_ == Tool::Bevel || tool_ == Tool::LoopCut || tool_ == Tool::Knife) tool_ = Tool::Move; + knifePts_.clear(); + if (Object* o = find(active_)) touched(o->id); + } + void clearElements() { selVerts_.clear(), selFaces_.clear(), selEdges_.clear(); } + void selectAllElements() { + Object* o = editing(); + if (!o) return; + clearElements(); + for (size_t v = 0; v < o->mesh.verts.size(); ++v) selVerts_.insert(int(v)); + for (size_t f = 0; f < o->mesh.faces.size(); ++f) selFaces_.insert(int(f)); + for (auto e : edges(o->mesh)) selEdges_.insert(e); + } + void selectAll() { + if (mode_ == Mode::Edit) return selectAllElements(); + selected_.clear(); + for (const Object& o : scene_.objects) + if (o.visible) selected_.insert(o.id), active_ = o.id; + } + void selectNone() { + if (mode_ == Mode::Edit) clearElements(); + else selected_.clear(); + } + // The points the selection covers, whichever kind is picked. + std::set selectedPoints() { + std::set pts; + Object* o = editing(); + if (!o) return pts; + if (elem_ == Elem::Vertex) pts = selVerts_; + else if (elem_ == Elem::Edge) + for (auto [a, b] : selEdges_) pts.insert(a), pts.insert(b); + else + for (int f : selFaces_) + if (f < int(o->mesh.faces.size())) + for (int v : o->mesh.faces[size_t(f)]) pts.insert(v); + return pts; + } + // Selects (in the current kind) what these points cover. + void selectPoints(const std::set& pts) { + Object* o = editing(); + if (!o) return; + clearElements(); + if (elem_ == Elem::Vertex) selVerts_ = pts; + else if (elem_ == Elem::Edge) { + for (auto [a, b] : edges(o->mesh)) + if (pts.count(a) && pts.count(b)) selEdges_.insert({a, b}); + } else + for (size_t f = 0; f < o->mesh.faces.size(); ++f) + if (std::all_of(o->mesh.faces[f].begin(), o->mesh.faces[f].end(), [&](int v) { return pts.count(v); })) selFaces_.insert(int(f)); + } + void growSelection() { + if (Object* o = editing()) selectPoints(growVerts(o->mesh, selectedPoints())); + } + void shrinkSelection() { + Object* o = editing(); + if (!o) return; + const std::set sel = selectedPoints(); + std::set rest; + for (size_t v = 0; v < o->mesh.verts.size(); ++v) + if (!sel.count(int(v))) rest.insert(int(v)); + const std::set border = growVerts(o->mesh, rest); + std::set keep; + for (int v : sel) + if (!border.count(v)) keep.insert(v); + selectPoints(keep); + } + void selectLinked() { + if (Object* o = editing()) selectPoints(linkedVerts(o->mesh, selectedPoints())); + } + void invertSelection() { + if (mode_ == Mode::Object) { + std::set inv; + for (const Object& o : scene_.objects) + if (o.visible && !selected_.count(o.id)) inv.insert(o.id), active_ = o.id; + selected_ = inv; + return; + } + Object* o = editing(); + if (!o) return; + if (elem_ == Elem::Vertex) { + std::set inv; + for (size_t v = 0; v < o->mesh.verts.size(); ++v) + if (!selVerts_.count(int(v))) inv.insert(int(v)); + selVerts_ = inv; + } else if (elem_ == Elem::Edge) { + std::set> inv; + for (auto e : edges(o->mesh)) + if (!selEdges_.count(e)) inv.insert(e); + selEdges_ = inv; + } else { + std::set inv; + for (size_t f = 0; f < o->mesh.faces.size(); ++f) + if (!selFaces_.count(int(f))) inv.insert(int(f)); + selFaces_ = inv; + } + } + // The edges a rounding would work on: those selected, or round the selected faces, or between + // selected points. + std::set> edgesToRound() { + std::set> out; + Object* o = editing(); + if (!o) return out; + if (elem_ == Elem::Edge) return selEdges_; + if (elem_ == Elem::Face) { + for (int f : selFaces_) { + const auto& fv = o->mesh.faces[size_t(f)]; + for (size_t i = 0; i < fv.size(); ++i) out.insert({std::min(fv[i], fv[(i + 1) % fv.size()]), std::max(fv[i], fv[(i + 1) % fv.size()])}); + } + return out; + } + for (auto [a, b] : edges(o->mesh)) + if (selVerts_.count(a) && selVerts_.count(b)) out.insert({a, b}); + return out; + } + + void deleteElements() { + Object* o = editing(); + if (!o) return; + pushUndo(); + if (elem_ == Elem::Face) deleteFaces(o->mesh, selFaces_); + else deleteVerts(o->mesh, selectedPoints()); + clearElements(); + handMade(*o); + touched(o->id); + } + // After a change that renumbers things: nothing selected, the shape is hand-made now. + void changedShape(Object& o, bool keepSelection = false) { + if (!keepSelection) clearElements(); + handMade(o); + touched(o.id); + } + void extrudeNow(float amount) { + Object* o = editing(); + if (!o || elem_ != Elem::Face || selFaces_.empty()) return say("Select faces to extrude (Faces, 3)"); + pushUndo(); + extrudeFaces(o->mesh, selFaces_, amount); + changedShape(*o, true); + } + void insetNow(float amount) { + Object* o = editing(); + if (!o || elem_ != Elem::Face || selFaces_.empty()) return say("Select faces to inset (Faces, 3)"); + pushUndo(); + insetFaces(o->mesh, selFaces_, amount); + changedShape(*o, true); + } + void bevelNow() { + Object* o = editing(); + const auto es = edgesToRound(); + if (!o || es.empty()) return say("Select edges (or faces) to round"); + pushUndo(); + bevelEdges(o->mesh, es, bevelWidth_, bevelSteps_); + changedShape(*o); + if (bevelSteps_ > 1) o->smooth = true; + } + void subdivideNow(bool smooth) { + Object* o = editing(); + if (!o) return; + pushUndo(); + subdivide(o->mesh, smooth); + if (smooth) o->smooth = true; + changedShape(*o); + } + void cutAt(Object& o, std::pair edge) { + pushUndo(); + const auto made = loopCut(o.mesh, edge, cuts_); + if (made.empty()) { + undo_.pop_back(); + return say("Nothing to cut there: loop cuts go across four-sided faces"); + } + changedShape(o); + // The new edges are selected, ready to move (along the faces, with the gizmo). + const std::set pts(made.begin(), made.end()); + elem_ = Elem::Edge; + for (auto [a, b] : edges(o.mesh)) + if (pts.count(a) && pts.count(b)) selEdges_.insert({a, b}); + say(std::format("Cut {} time{} round. W moves the new edges.", cuts_, cuts_ == 1 ? "" : "s")); + } + void makeFaceNow() { + Object* o = editing(); + if (!o) return; + const std::set pts = selectedPoints(); + pushUndo(); + const glm::vec3 towards = glm::inverse(glm::mat3(objectMatrix(*o))) * (eye_ - pivotOfSelection()); + if (!makeFace(o->mesh, std::vector(pts.begin(), pts.end()), towards)) { + undo_.pop_back(); + return say("Pick 3 or more points (not in a line) to make a face"); + } + changedShape(*o); + selFaces_ = {int(o->mesh.faces.size()) - 1}; + elem_ = Elem::Face; + } + void mergeNow() { + Object* o = editing(); + if (!o) return; + const std::set pts = selectedPoints(); + if (pts.size() < 2) return say("Select 2 or more points to merge"); + pushUndo(); + mergeToCenter(o->mesh, pts); + changedShape(*o); + } + void flipNow() { + Object* o = editing(); + if (!o || selFaces_.empty()) return say("Select faces to flip"); + pushUndo(); + flipFaces(o->mesh, selFaces_); + changedShape(*o, true); + } + // Rotation and scale baked into the points (so the object's turn is none and its size is 1). + void applyTransform(Object& o) { + pushUndo(); + transform(o.mesh, glm::mat4_cast(o.rotation) * glm::scale(glm::mat4(1.0f), o.scale)); + o.rotation = glm::quat(1, 0, 0, 0), o.scale = glm::vec3(1.0f); + handMade(o); + touched(o.id); + } + void applyFinish(Object& o) { + pushUndo(); + o.mesh = finishedMesh(o); + o.mirror = 0, o.round = 0, o.smoothLevels = 0; + handMade(o); + touched(o.id); + } + void originToMiddle(Object& o) { + pushUndo(); + const glm::vec3 c = (boundsMin(o.mesh) + boundsMax(o.mesh)) * 0.5f; + for (glm::vec3& v : o.mesh.verts) v -= c; + o.position += glm::vec3(objectMatrix(o) * glm::vec4(c, 0.0f)); + handMade(o); + touched(o.id); + } + void originToBottom(Object& o) { + pushUndo(); + const glm::vec3 c((boundsMin(o.mesh).x + boundsMax(o.mesh).x) * 0.5f, (boundsMin(o.mesh).y + boundsMax(o.mesh).y) * 0.5f, boundsMin(o.mesh).z); + for (glm::vec3& v : o.mesh.verts) v -= c; + o.position += glm::vec3(objectMatrix(o) * glm::vec4(c, 0.0f)); + handMade(o); + touched(o.id); + } + void mirror(Object& o, int axis) { + pushUndo(); + glm::vec3 s(1.0f); + s[axis] = -1.0f; + transform(o.mesh, glm::scale(glm::mat4(1.0f), s)); + handMade(o); + touched(o.id); + } + + + // ───────────────────────────── tabs ───────────────────────────── + + void setTab(Tab t) { + if (t == tab_) return; + if (drag_.active) cancelDrag(); + lockLayer(); + endStroke(); + knifePts_.clear(), drawPts_.clear(); + if (t != Tab::Model) leaveEdit(); + if ((t == Tab::Render || t == Tab::Print) && tool_ != Tool::Select && tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) tool_ = Tool::Move; + if ((t == Tab::Paint || t == Tab::Layout) && tab_ != Tab::Paint && tab_ != Tab::Layout) picZoom_ = 0; // (fit the picture again) + lay_.drag = 0, printTool_ = PrintTool::None, measure_.clear(); + tab_ = t; + } + Shading shading() const { return tab_ == Tab::Render ? Shading::Rendered : shading_; } + + // ───────────────────────────── the knife and holes ───────────────────────────── + + // Snapped to steps of 45 degrees from the last point while Ctrl is held. + ImVec2 knifeEnd(const ImGuiIO& io) const { + ImVec2 m = io.MousePos; + const bool have = !knifePts_.empty() || knifeDown_; + if (!io.KeyCtrl || !have) return m; + const ImVec2 from = knifePts_.empty() ? knifePress_ : knifePts_.back(); + const float dx = m.x - from.x, dy = m.y - from.y, len = std::hypot(dx, dy); + const float a = std::round(std::atan2(dy, dx) / (kPi / 4)) * (kPi / 4); + return ImVec2(from.x + std::cos(a) * len, from.y + std::sin(a) * len); + } + static Mesh subMesh(const Mesh& m, const std::set& faces) { + Mesh p; + p.verts = m.verts; + for (int f : faces) p.faces.push_back(m.faces[size_t(f)]); + removeUnused(p); + return p; + } + // A new object like `from` (its look, place and finish), with another shape. + uint32_t objectLike(uint32_t from, Mesh mesh, const std::string& name) { + const Object copy = *find(from); + Object& n = addObject(name, std::move(mesh)); + const uint32_t id = n.id; + const std::string unique = n.name; + Mesh m = std::move(n.mesh); + n = copy, n.id = id, n.name = unique, n.mesh = std::move(m), n.locked = false; + n.recipe.clear(), n.settings.clear(), n.uvs.clear(); + touched(id); + return id; + } + void knifeApply(uint32_t id, std::vector pts) { + Object* o = find(id); + if (!o || pts.size() < 2) return; + if (knifeThrough_) { // (on past its ends, so a line cuts right across) + auto past = [](ImVec2 a, ImVec2 b) { + const float dx = a.x - b.x, dy = a.y - b.y, l = std::max(std::hypot(dx, dy), 1e-3f); + return ImVec2(a.x + dx / l * 4000.0f, a.y + dy / l * 4000.0f); + }; + pts.front() = past(pts[0], pts[1]); + pts.back() = past(pts[pts.size() - 1], pts[pts.size() - 2]); + } + const glm::mat4 inv = glm::inverse(objectMatrix(*o)); + const glm::vec3 eye(inv * glm::vec4(eye_, 1.0f)); + std::vector path; + for (ImVec2 p : pts) path.push_back(glm::vec3(inv * glm::vec4(eye_ + mouseRay(p).dir * dist_, 1.0f))); + pushUndo(); + const auto made = knifeCut(o->mesh, eye, path, knifeThrough_); + if (made.empty()) { + undo_.pop_back(); + return say(knifeThrough_ ? "The line didn't cross the shape" : "The line didn't cross any face you can see (Tool options: cut right through)"); + } + clearElements(); + handMade(*o); + touched(id); + if (!knifeThrough_ || knifeAfter_ == 0) { + elem_ = Elem::Edge, selEdges_ = made; + return say(std::format("Cut: {} new edge{} (selected)", made.size(), made.size() == 1 ? "" : "s")); + } + auto pieces = splitAlong(o->mesh, made, knifeFill_); + if (pieces.size() < 2) { + elem_ = Elem::Edge, selEdges_ = made; + return say("Cut, but not all the way round, so it's still one piece"); + } + auto size = [&](const std::set& f) { + if (knifeFill_) return volumeOf(o->mesh, f); + const Mesh p = subMesh(o->mesh, f); + const glm::vec3 d = boundsMax(p) - boundsMin(p); + return d.x * d.y * d.z + 1e-9f * float(f.size()); + }; + std::sort(pieces.begin(), pieces.end(), [&](const std::set& a, const std::set& b) { return size(a) > size(b); }); + const size_t n = pieces.size(); + if (knifeAfter_ == 1) { + selFaces_ = pieces[1], elem_ = Elem::Face; + return say(std::format("Cut into {} pieces (the smaller selected: W moves it, P makes it its own object, Del deletes it)", n)); + } + std::set others; + for (size_t i = 1; i < n; ++i) others.insert(pieces[i].begin(), pieces[i].end()); + std::vector parts; + if (knifeAfter_ == 2) + for (size_t i = 1; i < n; ++i) parts.push_back(subMesh(o->mesh, pieces[i])); + deleteFaces(o->mesh, others); + removeUnused(o->mesh); + const std::string base = o->name; + for (Mesh& part : parts) objectLike(id, std::move(part), base + " piece"); + touched(id); + say(knifeAfter_ == 2 ? std::format("Cut into {} objects (see Layers)", n) : "Cut off: the smaller part is gone"); + } + static float objectScale(const Object& o) { return std::max(std::cbrt(std::abs(glm::determinant(glm::mat3(objectMatrix(o))))), 1e-6f); } + // How wide (across) a shape is when it reaches `reach` from its middle (dragging it out). + float holeAcrossFor(float reach) const { + return HoleShape(holeShape_) == HoleShape::Triangle || HoleShape(holeShape_) == HoleShape::Hexagon ? reach * std::sqrt(3.0f) : reach * 2.0f; + } + // The outline, `across` wide in the object's own size (a rectangle keeps its proportions). + std::vector holeShapeOutline(const Object& o, int face, const glm::vec3& centre, float across) const { + const float tall = across * holeTall_ / std::max(holeSize_, 1e-6f); + return holeOutline(o.mesh, face, centre, HoleShape(holeShape_), across, tall, holeSides_, holeTurn_); + } + void applyHole(uint32_t id, int face, const glm::vec3& centre, float across) { + Object* o = find(id); + if (!o || face < 0 || face >= int(o->mesh.faces.size()) || across <= 0) return; + const float scale = objectScale(*o); + static const char* names[] = {"A round", "A rectangular", "A triangular", "A hexagonal"}; + const auto outline = holeShapeOutline(*o, face, centre, across); + pushUndo(); + holeTall_ *= across * scale / std::max(holeSize_, 1e-6f), holeSize_ = across * scale; + if (holeMode_ == 2) { // just the outline: a new face, picked to extrude, bevel or paint + const int made = imprintOutline(o->mesh, face, outline); + if (made < 0) return undo_.pop_back(), say("The outline has to fit inside the face"); + changedShape(*o); + selected_ = {id}, active_ = id; + if (mode_ != Mode::Edit) enterEdit(); + elem_ = Elem::Face, selFaces_ = {made}; + return say(std::format("{} outline cut, {:.1f} mm across: its new face is picked (Extrude, Bevel, or paint it)", names[holeShape_], holeSize_ * 1000)); + } + if (!cutHole(o->mesh, face, outline, holeMode_ == 0 ? 0.0f : holeDepth_ / scale)) { + undo_.pop_back(); + return say(holeMode_ == 0 ? "The hole has to fit inside the face, and inside the face behind it (it goes through)" : "The hole has to fit inside the face"); + } + changedShape(*o); + say(std::format("{} hole, {:.1f} mm across{}", names[holeShape_], holeSize_ * 1000, + holeMode_ == 0 ? ", all the way through" : std::format(", {:.1f} mm deep", holeDepth_ * 1000))); + } + // The hole tool's outline under the mouse (or being dragged out), in the world. + std::vector holePreview(const ImGuiIO& io) { + uint32_t id = hole_.object; + int face = hole_.face; + glm::vec3 centre = hole_.centre; + float across = holeAcrossFor(hole_.radius); + if (!hole_.active) { + const Ray ray = mouseRay(io.MousePos); + const Hit h = pickObject(ray, mode_ == Mode::Edit ? active_ : 0, true); + if (!h.object) return {}; + const Object* o = find(h.object); + id = h.object, face = h.face; + centre = glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(ray.origin + ray.dir * h.t, 1.0f)); + across = holeSize_ / objectScale(*o); + } + const Object* o = find(id); + if (!o || face < 0 || face >= int(o->mesh.faces.size())) return {}; + const glm::mat4 m = objectMatrix(*o); + std::vector out; + for (const glm::vec3& p : holeShapeOutline(*o, face, centre, across)) out.push_back(glm::vec3(m * glm::vec4(p, 1.0f))); + return out; + } + // Where the mouse is on a face's plane (the object's own space). + std::optional onFacePlane(const Object& o, int face, ImVec2 mouse) { + const glm::mat4 inv = glm::inverse(objectMatrix(o)); + const Ray r = mouseRay(mouse); + const Ray local{glm::vec3(inv * glm::vec4(r.origin, 1.0f)), glm::normalize(glm::vec3(inv * glm::vec4(r.dir, 0.0f)))}; + return hitPlane(local, faceCenter(o.mesh, face), faceNormal(o.mesh, face)); + } + + // ───────────────────────────── painting ───────────────────────────── + + // The object ready to be painted: its rounding and smoothing made part of it, its surface laid + // out on a picture (made the plain colour of it at first). + Object* readyToPaint(uint32_t id) { + Object* o = find(id); + if (!o) return nullptr; + if (o->locked) return say("That object is locked"), nullptr; + if (o->picture && o->uvsFit() && !o->hasFinish()) return o; + pushUndo(); + if (o->hasFinish()) { + o->mesh = finishedMesh(*o); + o->mirror = 0, o->round = 0, o->smoothLevels = 0; + handMade(*o); + } + o->uvs = autoUnwrap(o->mesh); + if (!o->picture) o->picture = std::make_shared(makeImage(pictureSize_, pictureSize_, glm::vec4(o->color, 1.0f))); + touched(o->id); + picZoom_ = 0; + say(std::format("{} is ready to paint: its surface is laid out flat on a {}x{} picture", o->name, o->picture->w, o->picture->h)); + return o; + } + // The object's picture, copied so this change can be undone; the copy is now the object's. + Image& changePicture(Object& o) { + pushUndo(); + auto img = std::make_shared(*o.picture); + o.picture = img; + paint_.image = img; + uploaded_[o.id].dirty = true; + return *img; + } + void beginStroke(Object& o, bool in2D) { + Image& img = changePicture(o); + paint_.active = true, paint_.in2D = in2D, paint_.haveLast = false, paint_.object = o.id; + paint_.stroke.begin(img, paintTool_ == PaintTool::Eraser ? glm::vec4(o.color, 1.0f) : paintColor_); + } + void endStroke() { + if (!paint_.active) return; + paint_.active = false; + uploaded_[paint_.object].dirty = true; + paint_.image.reset(); + paint_.stroke = {}; + } + // Where on its picture a ray hits an object (0..1), or nothing. + std::optional uvUnder(const Object& o, const Ray& ray) { + if (!o.uvsFit()) return std::nullopt; + const glm::mat4 m = objectMatrix(o); + float best = 1e30f; + std::optional out; + for (size_t f = 0; f < o.mesh.faces.size(); ++f) { + const auto& fv = o.mesh.faces[f]; + for (const auto& t : triangulate(o.mesh, int(f))) { + glm::vec3 w[3]; + glm::vec2 uv[3]; + for (int k = 0; k < 3; ++k) { + w[k] = glm::vec3(m * glm::vec4(o.mesh.verts[size_t(t[size_t(k)])], 1.0f)); + uv[k] = o.uvs[f][size_t(std::find(fv.begin(), fv.end(), t[size_t(k)]) - fv.begin())]; + } + const glm::vec3 e1 = w[1] - w[0], e2 = w[2] - w[0], p = glm::cross(ray.dir, e2); + const float det = glm::dot(e1, p); + if (std::abs(det) < 1e-12f) continue; + const glm::vec3 sv = ray.origin - w[0], q = glm::cross(sv, e1); + const float u = glm::dot(sv, p) / det, v = glm::dot(ray.dir, q) / det, tt = glm::dot(e2, q) / det; + if (u < 0 || v < 0 || u + v > 1 || tt <= 0 || tt >= best) continue; + best = tt, out = uv[0] * (1 - u - v) + uv[1] * u + uv[2] * v; + } + } + return out; + } + // One dab of the brush on the model: on every face near it, into the picture where that face + // lies (so it carries on across the seams between pieces of the layout). + void dab3D(Object& o, const glm::vec3& at, float radius) { + Image& im = *paint_.image; + const glm::mat4 m = objectMatrix(o); + const glm::vec2 size(float(im.w), float(im.h)); + const float inner = std::clamp(hardness_, 0.0f, 0.999f) * radius; + for (size_t f = 0; f < o.mesh.faces.size(); ++f) { + const auto& fv = o.mesh.faces[f]; + for (const auto& t : triangulate(o.mesh, int(f))) { + glm::vec3 w[3]; + glm::vec2 px[3]; + for (int k = 0; k < 3; ++k) { + w[k] = glm::vec3(m * glm::vec4(o.mesh.verts[size_t(t[size_t(k)])], 1.0f)); + px[k] = o.uvs[f][size_t(std::find(fv.begin(), fv.end(), t[size_t(k)]) - fv.begin())] * size; + } + const glm::vec3 lo = glm::min(w[0], glm::min(w[1], w[2])) - radius, hi = glm::max(w[0], glm::max(w[1], w[2])) + radius; + if (glm::any(glm::lessThan(at, lo)) || glm::any(glm::greaterThan(at, hi))) continue; + const glm::vec3 n = glm::cross(w[1] - w[0], w[2] - w[0]); + const float area3 = glm::length(n) * 0.5f; + if (area3 < 1e-12f || glm::dot(n, eye_ - w[0]) <= 0 || std::abs(glm::dot(glm::normalize(n), at - w[0])) > radius) continue; + const float areaPx = std::abs((px[1].x - px[0].x) * (px[2].y - px[0].y) - (px[2].x - px[0].x) * (px[1].y - px[0].y)) * 0.5f; + if (areaPx < 1e-6f) continue; + // The dab's middle on the picture, and how far it reaches there. + const glm::vec3 nn = glm::normalize(n), c = at - nn * glm::dot(nn, at - w[0]); + const glm::vec3 v0 = w[1] - w[0], v1 = w[2] - w[0], v2 = c - w[0]; + const float d00 = glm::dot(v0, v0), d01 = glm::dot(v0, v1), d11 = glm::dot(v1, v1), d20 = glm::dot(v2, v0), d21 = glm::dot(v2, v1), den = d00 * d11 - d01 * d01; + const float bv = (d11 * d20 - d01 * d21) / den, bw = (d00 * d21 - d01 * d20) / den; + const glm::vec2 mid = px[0] * (1 - bv - bw) + px[1] * bv + px[2] * bw; + const float reach = radius * std::sqrt(areaPx / area3) * 1.5f + 2.0f; + glm::vec2 plo = glm::max(glm::min(px[0], glm::min(px[1], px[2])) - 2.0f, mid - reach), phi = glm::min(glm::max(px[0], glm::max(px[1], px[2])) + 2.0f, mid + reach); + plo = glm::max(plo, glm::vec2(0.0f)), phi = glm::min(phi, size - 1.0f); + const float twice = areaPx * 2.0f * ((px[1].x - px[0].x) * (px[2].y - px[0].y) - (px[2].x - px[0].x) * (px[1].y - px[0].y) > 0 ? 1.0f : -1.0f); + for (int y = int(plo.y); y <= int(phi.y); ++y) + for (int x = int(plo.x); x <= int(phi.x); ++x) { + const glm::vec2 q(float(x) + 0.5f, float(y) + 0.5f); + float l[3]; + bool ok = true; + for (int k = 0; k < 3; ++k) { + const glm::vec2 a = px[(k + 1) % 3], b = px[(k + 2) % 3]; + l[k] = ((b.x - a.x) * (q.y - a.y) - (q.x - a.x) * (b.y - a.y)) / twice; + ok &= l[k] * std::abs(twice) / std::max(glm::length(b - a), 1e-6f) >= -1.5f; // (a pixel and a half past the edge: no seams) + } + if (!ok) continue; + const glm::vec3 p = w[0] * l[0] + w[1] * l[1] + w[2] * l[2]; + const float d = glm::length(p - at); + if (d >= radius) continue; + const float s = d <= inner ? 1.0f : 1.0f - (d - inner) / (radius - inner); + paint_.stroke.put(im, x, y, s * s * (3 - 2 * s)); + } + } + } + uploaded_[o.id].dirty = true; + } + // The brush at a point of the view: starts a stroke on what's there, or carries one on. + void paintAt(ImVec2 mouse, bool start) { + const Ray ray = mouseRay(mouse); + if (start) { + const Hit h = pickObject(ray, 0, true); + if (!h.object) return; + selected_ = {h.object}, active_ = h.object; + Object* o = readyToPaint(h.object); + if (!o) return; + beginStroke(*o, false); + } + if (!paint_.active || paint_.in2D) return; + Object* o = find(paint_.object); + if (!o) return; + const Hit h = pickObject(ray, o->id, true); + if (!h.object) { + paint_.haveLast = false; + return; + } + const glm::vec3 at = ray.origin + ray.dir * h.t; + const float radius = brushPx_ * pixelSize(at); + const float gap = glm::distance(at, paint_.last); + if (paint_.haveLast && gap < radius * 8) { + const int steps = std::max(1, int(std::ceil(gap / (radius * 0.3f)))); + for (int i = 1; i <= steps; ++i) dab3D(*o, glm::mix(paint_.last, at, float(i) / float(steps)), radius); + } else + dab3D(*o, at, radius); + paint_.last = at, paint_.haveLast = true; + } + void pickColor(const Object& o, glm::vec2 uv) { + const glm::vec4 c = sample(*o.picture, uv * glm::vec2(o.picture->w, o.picture->h)); + paintColor_ = glm::vec4(glm::vec3(c), paintColor_.a); + } + void fillAt(Object& o, glm::vec2 uv) { + Image& img = changePicture(o); + floodFill(img, glm::ivec2(uv * glm::vec2(img.w, img.h)), glm::vec4(glm::vec3(paintColor_), 1.0f), fillTolerance_); + paint_.image.reset(); + } + + // ── Picture layers ── + + PictureLayer* editLayer() { + Object* o = find(ledit_.object); + return o && ledit_.index >= 0 && ledit_.index < int(o->layers.size()) ? &o->layers[size_t(ledit_.index)] : nullptr; + } + // A picture put on the selected object as a new layer, unlocked to be moved: a sticker on the + // surface in the middle of the view, or pinned onto the flat picture. + void startFloating(Image img) { + Object* o = selected_.empty() ? nullptr : readyToPaint(active_); + if (!o) return say("Select the object to put the picture on first"); + if (img.empty()) return; + lockLayer(); + pushUndo(); + PictureLayer l; + l.name = std::format("Picture {}", o->layers.size() + 1); + l.image = std::make_shared(std::move(img)); + l.sticker = pasteOnSurface_; + const float aspect = float(l.image->h) / float(std::max(l.image->w, 1)); + if (l.sticker) { + const glm::mat4 m = objectMatrix(*o), inv = glm::inverse(m); + const Ray ray = mouseRay(ImVec2((viewMin_.x + viewMax_.x) * 0.5f, (viewMin_.y + viewMax_.y) * 0.5f)); + const Hit h = pickObject(ray, o->id, true); + if (h.object) l.face = h.face, l.centre = glm::vec3(inv * glm::vec4(ray.origin + ray.dir * h.t, 1.0f)); + else { // (the face looking most at you) + float best = -2; + const glm::vec3 toEye = glm::normalize(glm::vec3(inv * glm::vec4(-viewDir(), 0.0f))); + for (size_t f = 0; f < o->mesh.faces.size(); ++f) + if (const float d = glm::dot(faceNormal(o->mesh, int(f)), toEye); d > best) best = d, l.face = int(f), l.centre = faceCenter(o->mesh, int(f)); + } + // Upright as you see it, a third as big as the object. + const glm::vec3 f = viewDir(), right = glm::normalize(glm::cross(f, std::abs(f.z) > 0.99f ? glm::vec3(std::cos(glm::radians(yaw_)), std::sin(glm::radians(yaw_)), 0) : glm::vec3(0, 0, 1))); + l.up = glm::normalize(glm::vec3(inv * glm::vec4(glm::cross(right, f), 0.0f))); + const glm::vec3 d = boundsMax(o->mesh) - boundsMin(o->mesh); + l.width = std::max({d.x, d.y, d.z}) * 0.35f, l.height = l.width * aspect; + } else { + const float W = float(o->picture->w), H = float(o->picture->h); + const float k = std::min(W * 0.6f / float(l.image->w), H * 0.6f / float(l.image->h)); + const glm::vec2 half(float(l.image->w) * k * 0.5f, float(l.image->h) * k * 0.5f), c(W * 0.5f, H * 0.5f); + l.corners = {c - half, glm::vec2(c.x + half.x, c.y - half.y), c + half, glm::vec2(c.x - half.x, c.y + half.y)}; + } + o->layers.push_back(std::move(l)); + ledit_ = {}; + ledit_.object = o->id, ledit_.index = int(o->layers.size()) - 1, ledit_.fresh = true; + paintTool_ = PaintTool::Picture; + if (tab_ != Tab::Paint) setTab(Tab::Paint); + say(pasteOnSurface_ ? "Drag it round the model (it bends over edges); corners size it (Shift: stretch), the round handle turns it. Enter locks it." + : "Drag its corners in the Picture panel to fit (Shift: keep its shape), inside to move it. Enter locks it."); + } + void floatFromFile(const std::string& path) { + Image img; + if (!loadImageFile(path, img)) return say("Couldn't load " + path); + startFloating(std::move(img)); + } + void pasteFromClipboard() { + for (const char* mime : {"image/png", "image/bmp", "image/jpeg"}) { + if (!SDL_HasClipboardData(mime)) continue; + size_t n = 0; + void* data = SDL_GetClipboardData(mime, &n); + if (!data) continue; + int w = 0, h = 0, c = 0; + stbi_uc* px = stbi_load_from_memory(static_cast(data), int(n), &w, &h, &c, 4); + SDL_free(data); + if (!px) continue; + Image img; + img.w = w, img.h = h, img.px.assign(px, px + size_t(w) * size_t(h) * 4); + stbi_image_free(px); + return startFloating(std::move(img)); + } + say("Nothing to paste: copy a picture in another app first (or use Add a picture from a file)"); + } + // Enter: it stays where it is, a layer (click it in the Picture panel to move it again). + void lockLayer() { + if (!editLayer()) return void(ledit_ = {}); + ledit_ = {}; + say("Locked in place, as a layer (see Properties). Click it in the Picture panel to move it again."); + } + void unlockLayer(uint32_t object, int index) { + lockLayer(); + ledit_.object = object, ledit_.index = index; + paintTool_ = PaintTool::Picture; + if (const PictureLayer* l = editLayer()) say(std::format("{} unlocked: {}. Enter locks it again.", l->name, l->sticker ? "drag it on the model" : "drag its corners in the Picture panel")); + } + // Esc: a picture just pasted goes again; one unlocked is locked again. + void cancelFloating() { + Object* o = find(ledit_.object); + if (o && editLayer() && ledit_.fresh) { + o->layers.erase(o->layers.begin() + ledit_.index); + ledit_ = {}; + return say("Taken away"); + } + lockLayer(); + } + void deleteLayer(Object& o, int i) { + if (i < 0 || i >= int(o.layers.size())) return; + pushUndo(); + o.layers.erase(o.layers.begin() + i); + ledit_ = {}; + } + // The layer painted into the painting for good (it can't be moved after). + void mergeLayer(Object& o, int i) { + if (i < 0 || i >= int(o.layers.size()) || !o.picture) return; + pushUndo(); + Object only = o; + only.layers = {o.layers[size_t(i)]}; + o.picture = std::make_shared(composePicture(only)); + o.layers.erase(o.layers.begin() + i); + ledit_ = {}; + } + // The handles of a sticker being moved, in the world: its corners (top-left, top-right, + // bottom-right, bottom-left), its turning handle, its middle — on its face's plane. + bool stickerHandles(const Object& o, const PictureLayer& l, std::array& out) { + glm::vec3 right, up, n; + if (!stickerFrame(o.mesh, l, right, up, n)) return false; + const int f = stickerFace(o.mesh, l); + const glm::vec3 c = l.centre - n * glm::dot(l.centre - faceCenter(o.mesh, f), n); + const float hw = l.width * 0.5f, hh = l.height * 0.5f; + const glm::mat4 m = objectMatrix(o); + auto w = [&](const glm::vec3& p) { return glm::vec3(m * glm::vec4(p, 1.0f)); }; + out = {w(c - right * hw + up * hh), w(c + right * hw + up * hh), w(c + right * hw - up * hh), w(c - right * hw - up * hh), w(c + up * (hh + std::max(hw, hh) * 0.4f)), w(c)}; + return true; + } + int stickerHandle(const Object& o, const PictureLayer& l, ImVec2 mouse) { + std::array h; + if (!stickerHandles(o, l, h)) return -1; + ImVec2 s[6]; + for (int i = 0; i < 6; ++i) + if (!project(h[size_t(i)], s[i])) return -1; + for (int i = 0; i < 4; ++i) + if (std::abs(mouse.x - s[i].x) < 10 && std::abs(mouse.y - s[i].y) < 10) return i; + if (std::hypot(mouse.x - s[4].x, mouse.y - s[4].y) < 11) return 5; + return insidePolygon(mouse, {s[0], s[1], s[2], s[3]}) ? 4 : -1; + } + // Moving a sticker on the model: it slides over the surface (onto other faces too), its + // corners size it, its handle turns it. + void stickerMouse(const ImGuiIO& io) { + Object* o = find(ledit_.object); + PictureLayer* l = editLayer(); + if (!o || !l) return; + const glm::mat4 inv = glm::inverse(objectMatrix(*o)); + const Ray ray = mouseRay(io.MousePos); + if (ledit_.drag >= 0) { + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) return void(ledit_.drag = -1); + const PictureLayer& s = ledit_.start; + glm::vec3 right, up, n; + stickerFrame(o->mesh, s, right, up, n); + if (ledit_.drag == 4) { + const Hit h = pickObject(ray, o->id, true); + if (!h.object) return; + const glm::vec3 p(inv * glm::vec4(ray.origin + ray.dir * h.t, 1.0f)); + l->face = h.face, l->up = s.up; + glm::vec3 r2, u2, n2; + stickerFrame(o->mesh, *l, r2, u2, n2); + l->up = u2; // (its top carried over onto the new face) + l->centre = p - r2 * ledit_.offset.x - u2 * ledit_.offset.y; + settleSticker(o->mesh, *l); + return; + } + const Ray local{glm::vec3(inv * glm::vec4(ray.origin, 1.0f)), glm::normalize(glm::vec3(inv * glm::vec4(ray.dir, 0.0f)))}; + const auto hp = hitPlane(local, s.centre, n); + if (!hp) return; + const glm::vec3 d = *hp - s.centre; + const float dx = glm::dot(d, right), dy = glm::dot(d, up); + if (ledit_.drag < 4) { + if (io.KeyShift) l->width = std::max(2 * std::abs(dx), 0.002f), l->height = std::max(2 * std::abs(dy), 0.002f); + else { + const float k = std::max(std::abs(dx) / (s.width * 0.5f), std::abs(dy) / (s.height * 0.5f)); + l->width = std::max(s.width * k, 0.002f), l->height = std::max(s.height * k, 0.002f); + } + message_ = std::format("{:.0f} x {:.0f} mm", l->width * 1000, l->height * 1000), messageAt_ = now(); + } else { + float a = std::atan2(dx, dy); + if (snap_ != io.KeyCtrl) a = glm::radians(snapTo(glm::degrees(a), 15.0f)); + l->up = up * std::cos(a) + right * std::sin(a); + message_ = std::format("Turned {:.0f} degrees", glm::degrees(a)), messageAt_ = now(); + } + return; + } + if (!mouseInView(io) || !ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return; + const int h = stickerHandle(*o, *l, io.MousePos); + if (h < 0 && !pickObject(ray, o->id, true).object) return; + pushUndo(); + ledit_.start = *l, ledit_.drag = h < 0 ? 4 : h, ledit_.offset = {0, 0}; + if (h == 4) { // (held where it was grabbed) + glm::vec3 right, up, n; + stickerFrame(o->mesh, *l, right, up, n); + const Ray local{glm::vec3(inv * glm::vec4(ray.origin, 1.0f)), glm::normalize(glm::vec3(inv * glm::vec4(ray.dir, 0.0f)))}; + if (auto hp = hitPlane(local, l->centre, n)) ledit_.offset = {glm::dot(*hp - l->centre, right), glm::dot(*hp - l->centre, up)}; + } + } + // A cheap fingerprint of what the object's picture shows (its painting, its layers, its shape). + uint64_t pictureStamp(const Object& o) { + uint64_t h = 1469598103934665603ull; + auto mix = [&](const void* p, size_t n) { + for (size_t i = 0; i < n; ++i) h = (h ^ static_cast(p)[i]) * 1099511628211ull; + }; + const void* base = o.picture.get(); + mix(&base, sizeof base); + const uint64_t version = versions_[o.id]; + mix(&version, sizeof version); + for (const PictureLayer& l : o.layers) { + const void* im = l.image.get(); + mix(&im, sizeof im), mix(&l.sticker, 1), mix(&l.visible, 1), mix(&l.opacity, 4), mix(l.corners.data(), 32); + mix(&l.face, 4), mix(&l.centre, 12), mix(&l.up, 12), mix(&l.width, 4), mix(&l.height, 4); + } + return h; + } + // The object's picture on the graphics card (its painting with its layers on top): sent again + // when it changes — while painting or dragging, at most 20 times a second. + void uploadPicture(const Object& o, bool now_ = false) { + if (!o.picture) return; + Uploaded& u = uploaded_[o.id]; + const bool checker = tab_ == Tab::Layout && layChecker_; + const uint64_t stamp = pictureStamp(o) ^ (checker ? 0x9E3779B97F4A7C15ull : 0); + const bool first = !paintTex_.count(o.id); + if (!first && u.stamp == stamp && !u.dirty) return; + if (!first && !now_ && now() - u.at < 0.05) return; + if (checker) { // (squares that show where the layout stretches or squashes the picture) + static Image board; + if (board.empty()) { + board = makeImage(512, 512, {1, 1, 1, 1}); + for (int y = 0; y < 512; ++y) + for (int x = 0; x < 512; ++x) { + const bool dark = ((x / 32) + (y / 32)) % 2; + const glm::vec3 c = glm::mix(glm::vec3(0.95f, 0.55f, 0.25f), glm::vec3(0.3f, 0.55f, 0.9f), float(y) / 511.0f) * (dark ? 0.55f : 1.0f); + uint8_t* p = board.at(x, y); + p[0] = uint8_t(c.r * 255), p[1] = uint8_t(c.g * 255), p[2] = uint8_t(c.b * 255); + } + } + paintTex_[o.id] = r_->createTexture(std::format("fry:paint:{}", o.id), 512, 512, board.px.data()); + } else if (o.layers.empty()) { + owner_.erase(o.id); + paintTex_[o.id] = r_->createTexture(std::format("fry:paint:{}", o.id), uint32_t(o.picture->w), uint32_t(o.picture->h), o.picture->px.data()); + } else { + const Image shown = composePicture(o, &owner_[o.id]); + paintTex_[o.id] = r_->createTexture(std::format("fry:paint:{}", o.id), uint32_t(shown.w), uint32_t(shown.h), shown.px.data()); + } + u = {stamp, now(), false}; + } + + // ───────────────────────────── the Layout tab ───────────────────────────── + // The frames an object's faces are laid out in on its picture, moved, turned and sized by + // hand (whole frames, or their corners); what's painted goes with them unless you say not. + + // What's picked, as (face, corner) pairs of its layout. + std::vector> layoutPicked(const Object& o, const std::vector>& islands) const { + std::vector> out; + if (lay_.object != o.id) return out; + if (lay_.corners) return {lay_.points.begin(), lay_.points.end()}; + for (int i : lay_.frames) + if (i < int(islands.size())) + for (int f : islands[size_t(i)]) + for (size_t c = 0; c < o.mesh.faces[size_t(f)].size(); ++c) out.push_back({f, int(c)}); + return out; + } + // A corner and every other on the same point of the model at the same place on the picture. + std::set> layoutCorner(const Object& o, int f, int c) const { + std::set> out; + const int v = o.mesh.faces[size_t(f)][size_t(c)]; + const glm::vec2 at = o.uvs[size_t(f)][size_t(c)]; + for (size_t g = 0; g < o.mesh.faces.size(); ++g) + for (size_t k = 0; k < o.mesh.faces[g].size(); ++k) + if (o.mesh.faces[g][k] == v && glm::distance(o.uvs[g][k], at) < 1e-5f) out.insert({int(g), int(k)}); + return out; + } + void layoutSelectAll() { + Object* o = selected_.empty() ? nullptr : find(active_); + if (!o || !o->uvsFit()) return; + if (lay_.object != o->id) lay_ = {}, lay_.object = o->id; + lay_.frames.clear(), lay_.points.clear(); + if (lay_.corners) + for (size_t f = 0; f < o->uvs.size(); ++f) + for (size_t c = 0; c < o->uvs[f].size(); ++c) lay_.points.insert({int(f), int(c)}); + else + for (size_t i = 0; i < uvIslands(o->mesh, o->uvs).size(); ++i) lay_.frames.insert(int(i)); + } + // The picked corners moved by `to` (picture pixels), painting carried along if wanted. + void layoutChange(Object& o, const std::vector>& from, const std::shared_ptr& picture, + const std::vector>& picked, const std::function& to, bool final) { + const glm::vec2 size(float(o.picture->w), float(o.picture->h)); + o.uvs = from; + std::set faces; + for (auto [f, c] : picked) o.uvs[size_t(f)][size_t(c)] = to(from[size_t(f)][size_t(c)] * size) / size, faces.insert(f); + touched(o.id); + if (layKeepPaint_ && picture && (final || now() - lay_.remapAt > 0.06)) { + Image img = *picture; + remapPicture(img, o.mesh, from, o.uvs, {faces.begin(), faces.end()}); + o.picture = std::make_shared(std::move(img)); + lay_.remapAt = now(); + } + } + // Buttons' changes (turn, flip, straighten): about the middle of what's picked. + void layoutApply(const std::function& to) { + Object* o = selected_.empty() ? nullptr : find(active_); + if (!o || !o->picture || !o->uvsFit()) return; + const auto picked = layoutPicked(*o, uvIslands(o->mesh, o->uvs)); + if (picked.empty()) return say("Pick some frames first (click them in the Picture panel or on the model)"); + pushUndo(); + const glm::vec2 size(float(o->picture->w), float(o->picture->h)); + glm::vec2 lo(1e30f), hi(-1e30f); + for (auto [f, c] : picked) lo = glm::min(lo, o->uvs[size_t(f)][size_t(c)] * size), hi = glm::max(hi, o->uvs[size_t(f)][size_t(c)] * size); + const glm::vec2 mid = (lo + hi) * 0.5f; + layoutChange(*o, o->uvs, o->picture, picked, [&](glm::vec2 p) { return to(p, mid); }, true); + } + void layoutStraighten() { + Object* o = selected_.empty() ? nullptr : find(active_); + if (!o || !o->picture || !o->uvsFit()) return; + const auto picked = layoutPicked(*o, uvIslands(o->mesh, o->uvs)); + std::set faces; + for (auto [f, c] : picked) faces.insert(f); + float best = -1, angle = 0; // (its longest edge, laid level or upright) + for (int f : faces) + for (size_t i = 0; i < o->uvs[size_t(f)].size(); ++i) { + const glm::vec2 d = (o->uvs[size_t(f)][(i + 1) % o->uvs[size_t(f)].size()] - o->uvs[size_t(f)][i]) * glm::vec2(o->picture->w, o->picture->h); + if (const float l = glm::length(d); l > best) best = l, angle = std::atan2(d.y, d.x); + } + const float quarter = glm::half_pi(), turn = std::round(angle / quarter) * quarter - angle; + layoutApply([&](glm::vec2 p, glm::vec2 m) { return m + glm::vec2(std::cos(turn) * (p - m).x - std::sin(turn) * (p - m).y, std::sin(turn) * (p - m).x + std::cos(turn) * (p - m).y); }); + } + // Laid out again by itself, what's painted carried over onto the new layout. + void layoutAgain(Object& o) { + pushUndo(); + const auto before = o.uvs; + o.uvs = autoUnwrap(o.mesh); + if (layKeepPaint_ && o.picture && before.size() == o.uvs.size()) { + Image img = *o.picture; + std::vector all(o.mesh.faces.size()); + for (size_t f = 0; f < all.size(); ++f) all[f] = int(f); + Image fresh = makeImage(img.w, img.h, glm::vec4(glm::vec3(o.color), 1.0f)); + fresh.px = img.px; + remapPicture(fresh, o.mesh, before, o.uvs, all); + o.picture = std::make_shared(std::move(fresh)); + } + lay_.frames.clear(), lay_.points.clear(); + touched(o.id); + say("Laid out again" + std::string(layKeepPaint_ ? ", what's painted carried over" : "")); + } + + template + void layoutCanvas(Object& o, ImDrawList* dl, F toScreen, glm::vec2 mouse, bool hovered, int W, int H) { + const ImGuiIO& io = ImGui::GetIO(); + if (lay_.object != o.id) lay_ = {}, lay_.object = o.id; + const glm::vec2 size{float(W), float(H)}; + const auto islands = uvIslands(o.mesh, o.uvs); + std::vector islandOf(o.mesh.faces.size(), -1); + for (size_t i = 0; i < islands.size(); ++i) + for (int f : islands[i]) islandOf[size_t(f)] = int(i); + auto screen = [&](int f, int c) { return toScreen(o.uvs[size_t(f)][size_t(c)] * size); }; + auto faceUnder = [&]() { + for (size_t f = 0; f < o.mesh.faces.size(); ++f) { + std::vector poly; + for (size_t c = 0; c < o.uvs[f].size(); ++c) poly.push_back(screen(int(f), int(c))); + if (insidePolygon(io.MousePos, poly)) return int(f); + } + return -1; + }; + auto cornerUnder = [&]() { + std::pair best{-1, -1}; + float d = 9.0f; + for (size_t f = 0; f < o.uvs.size(); ++f) + for (size_t c = 0; c < o.uvs[f].size(); ++c) { + const ImVec2 q = screen(int(f), int(c)); + if (const float e = std::hypot(q.x - io.MousePos.x, q.y - io.MousePos.y); e < d) d = e, best = {int(f), int(c)}; + } + return best; + }; + const auto picked = layoutPicked(o, islands); + std::set pickedFaces; + for (auto [f, c] : picked) pickedFaces.insert(f); + // Pressing: pick (what's under it, or a box), then move / turn / size what's picked. + if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !lay_.drag) { + bool hit = false, wasPicked = false; + if (lay_.corners) { + const auto [f, c] = cornerUnder(); + if (f >= 0) { + const auto same = layoutCorner(o, f, c); + hit = true, wasPicked = lay_.points.count({f, c}) > 0; + if (io.KeyShift && wasPicked) + for (auto p : same) lay_.points.erase(p); + else { + if (!io.KeyShift && !wasPicked) lay_.points.clear(); + lay_.points.insert(same.begin(), same.end()); + } + } + } else if (const int f = faceUnder(); f >= 0) { + const int i = islandOf[size_t(f)]; + hit = true, wasPicked = lay_.frames.count(i) > 0; + if (io.KeyShift && wasPicked) lay_.frames.erase(i); + else { + if (!io.KeyShift && !wasPicked) lay_.frames.clear(); + lay_.frames.insert(i); + } + } + const bool anything = !lay_.frames.empty() || !lay_.points.empty(); + const bool transform = layTool_ != LayTool::Select && anything && !(io.KeyShift && hit) && (hit || layTool_ != LayTool::Move); + if (transform) { + pushUndo(); + lay_.drag = 1, lay_.grab = mouse, lay_.start = o.uvs, lay_.startPicture = o.picture; + glm::vec2 lo(1e30f), hi(-1e30f); + for (auto [f, c] : layoutPicked(o, islands)) lo = glm::min(lo, o.uvs[size_t(f)][size_t(c)] * size), hi = glm::max(hi, o.uvs[size_t(f)][size_t(c)] * size); + lay_.centre = (lo + hi) * 0.5f; + } else if (!hit) { + if (!io.KeyShift) lay_.frames.clear(), lay_.points.clear(); + lay_.drag = 2, lay_.boxFrom = io.MousePos; + } + } + if (lay_.drag == 1) { + const glm::vec2 g = lay_.grab - lay_.centre, m = mouse - lay_.centre; + std::function to; + if (layTool_ == LayTool::Move || layTool_ == LayTool::Select) { + const glm::vec2 d = mouse - lay_.grab; + to = [d](glm::vec2 p) { return p + d; }; + message_ = std::format("Moved {:.0f}, {:.0f} px", d.x, d.y); + } else if (layTool_ == LayTool::Rotate) { + float a = std::atan2(m.y, m.x) - std::atan2(g.y, g.x); + if (io.KeyCtrl) a = glm::radians(snapTo(glm::degrees(a), 15.0f)); + const glm::vec2 c = lay_.centre; + to = [a, c](glm::vec2 p) { return c + glm::vec2(std::cos(a) * (p - c).x - std::sin(a) * (p - c).y, std::sin(a) * (p - c).x + std::cos(a) * (p - c).y); }; + message_ = std::format("Turned {:.0f} degrees", glm::degrees(a)); + } else { + glm::vec2 k(glm::length(m) / std::max(glm::length(g), 1.0f)); + if (io.KeyShift) // (only across or only up: whichever the drag goes along more) + k = std::abs(g.x) > std::abs(g.y) ? glm::vec2(m.x / (std::abs(g.x) > 1 ? g.x : 1.0f), 1.0f) : glm::vec2(1.0f, m.y / (std::abs(g.y) > 1 ? g.y : 1.0f)); + const glm::vec2 c = lay_.centre; + to = [k, c](glm::vec2 p) { return c + (p - c) * k; }; + message_ = std::format("Size {:.0f}% x {:.0f}%", k.x * 100, k.y * 100); + } + messageAt_ = now(); + const bool done = !ImGui::IsMouseDown(ImGuiMouseButton_Left); + layoutChange(o, lay_.start, lay_.startPicture, layoutPicked(o, islands), to, done); + if (done) lay_.drag = 0, lay_.startPicture.reset(); + } + // Drawing: every face's outline, what's picked filled; corners as dots. + const ImU32 fill = (kAccent & 0x00FFFFFF) | 0x38000000; + for (size_t f = 0; f < o.mesh.faces.size(); ++f) { + std::vector pts; + for (size_t c = 0; c < o.uvs[f].size(); ++c) pts.push_back(screen(int(f), int(c))); + const bool on = pickedFaces.count(int(f)) && !lay_.corners; + if (on) + for (const auto& t : triangulate(o.mesh, int(f))) { + ImVec2 q[3]; + for (int k = 0; k < 3; ++k) q[k] = pts[size_t(std::find(o.mesh.faces[f].begin(), o.mesh.faces[f].end(), t[size_t(k)]) - o.mesh.faces[f].begin())]; + dl->AddTriangleFilled(q[0], q[1], q[2], fill); + } + dl->AddPolyline(pts.data(), int(pts.size()), on ? kAccent : IM_COL32(255, 255, 255, 150), ImDrawFlags_Closed, on ? 2.0f : 1.2f); + } + if (lay_.corners) { + const auto hot = cornerUnder(); + for (size_t f = 0; f < o.uvs.size(); ++f) + for (size_t c = 0; c < o.uvs[f].size(); ++c) { + const ImVec2 q = screen(int(f), int(c)); + const bool on = lay_.points.count({int(f), int(c)}) > 0; + dl->AddCircleFilled(q, on ? 4.5f : 3.0f, on ? kAccent : hot == std::pair{int(f), int(c)} ? kHot : IM_COL32(255, 255, 255, 200)); + } + } else if (hovered && !lay_.drag) + if (const int f = faceUnder(); f >= 0 && !lay_.frames.count(islandOf[size_t(f)])) + for (int g : islands[size_t(islandOf[size_t(f)])]) { + std::vector pts; + for (size_t c = 0; c < o.uvs[size_t(g)].size(); ++c) pts.push_back(screen(g, int(c))); + dl->AddPolyline(pts.data(), int(pts.size()), kHot, ImDrawFlags_Closed, 1.5f); + } + if (lay_.drag == 1 && layTool_ != LayTool::Move && layTool_ != LayTool::Select) { // (the middle it turns or sizes about) + const ImVec2 c = toScreen(lay_.centre); + dl->AddCircle(c, 6, kAccent, 16, 2.0f); + dl->AddLine(c, io.MousePos, (kAccent & 0x00FFFFFF) | 0x90000000, 1.0f); + } + if (lay_.drag == 2) { // a box to pick with + const ImVec2 a = lay_.boxFrom, b = io.MousePos; + dl->AddRectFilled(ImVec2(std::min(a.x, b.x), std::min(a.y, b.y)), ImVec2(std::max(a.x, b.x), std::max(a.y, b.y)), IM_COL32(255, 120, 40, 30)); + dl->AddRect(ImVec2(std::min(a.x, b.x), std::min(a.y, b.y)), ImVec2(std::max(a.x, b.x), std::max(a.y, b.y)), kAccent); + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + auto in = [&](ImVec2 q) { return q.x >= std::min(a.x, b.x) && q.x <= std::max(a.x, b.x) && q.y >= std::min(a.y, b.y) && q.y <= std::max(a.y, b.y); }; + for (size_t f = 0; f < o.uvs.size(); ++f) + for (size_t c = 0; c < o.uvs[f].size(); ++c) + if (in(screen(int(f), int(c)))) { + if (lay_.corners) lay_.points.insert({int(f), int(c)}); + else lay_.frames.insert(islandOf[f]); + } + lay_.drag = 0; + } + } + } + // In the 3D view: clicking the model picks the frame its face is in (Shift: add). + void layoutViewport(const ImGuiIO& io) { + if (!mouseInView(io) || !ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return; + const Hit h = pickObject(mouseRay(io.MousePos), 0, true); + if (!h.object) { + if (!io.KeyShift) lay_.frames.clear(), lay_.points.clear(); + return; + } + if (h.object != active_ || selected_.empty()) selected_ = {h.object}, active_ = h.object, lay_ = {}, lay_.object = h.object; + Object* o = find(h.object); + if (!o || !o->uvsFit()) return; + const auto islands = uvIslands(o->mesh, o->uvs); + for (size_t i = 0; i < islands.size(); ++i) + if (std::find(islands[i].begin(), islands[i].end(), h.face) != islands[i].end()) { + if (lay_.corners) lay_.corners = false, lay_.points.clear(); + if (!io.KeyShift) lay_.frames.clear(); + if (io.KeyShift && lay_.frames.count(int(i))) lay_.frames.erase(int(i)); + else lay_.frames.insert(int(i)); + } + } + void layoutOverlay(ImDrawList* dl) { + Object* o = selected_.empty() ? nullptr : find(active_); + if (!o || !o->uvsFit() || lay_.object != o->id) return; + const auto picked = layoutPicked(*o, uvIslands(o->mesh, o->uvs)); + std::set faces; + for (auto [f, c] : picked) faces.insert(f); + const glm::mat4 m = objectMatrix(*o); + for (int f : faces) { + std::vector pts; + bool ok = true; + for (int v : o->mesh.faces[size_t(f)]) { + ImVec2 q; + ok &= project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)), q); + pts.push_back(q); + } + if (ok) dl->AddPolyline(pts.data(), int(pts.size()), kAccent, ImDrawFlags_Closed, 2.0f); + } + } + void layoutToolbox(float w) { + grid(int(std::size(kLayTools)), 38.0f, [&](int i) { + const LayToolInfo& t = kLayTools[i]; + const std::string id = std::format("##{}", t.name), tip = std::format("{} ({})\n{}", t.name, t.key, t.hint); + if (iconButton(id.c_str(), t.icon, layTool_ == t.tool, tip.c_str())) layTool_ = t.tool; + }); + ImGui::Spacing(); + ImGui::TextDisabled("Pick"); + if (wideButton("##frames", Icon::Faces, "Frames", !lay_.corners, w, "Whole frames: faces laid out together (Tab)")) lay_.corners = false, lay_.points.clear(); + if (wideButton("##corners", Icon::Points, "Corners", lay_.corners, w, "Corners of the frames, to bend them (Tab)")) lay_.corners = true, lay_.frames.clear(); + } + void layoutOptions(float w) { + for (const LayToolInfo& t : kLayTools) + if (t.tool == layTool_) ImGui::SeparatorText(t.name); + ImGui::TextDisabled("What's painted"); + int keep = layKeepPaint_ ? 0 : 1; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##keep", &keep, "Stays on the model (moves too)\0Stays on the picture\0")) layKeepPaint_ = keep == 0; + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Stays on the model: the model looks the same, frames just get more or less of the picture.\n" + "Stays on the picture: the model shows whatever the frame is moved over (to fit a frame onto something drawn)."); + ImGui::Checkbox("Show squares on the model", &layChecker_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Squares instead of the painting: where they're stretched or squashed, so is the picture"); + ImGui::SeparatorText("What's picked"); + const float half = (w - ImGui::GetStyle().ItemSpacing.x) * 0.5f; + auto turn = [&](float deg) { + const float a = glm::radians(deg); + layoutApply([a](glm::vec2 p, glm::vec2 m) { return m + glm::vec2(std::cos(a) * (p - m).x - std::sin(a) * (p - m).y, std::sin(a) * (p - m).x + std::cos(a) * (p - m).y); }); + }; + if (ImGui::Button("Turn left", ImVec2(half, 0))) turn(-90); + ImGui::SameLine(); + if (ImGui::Button("Turn right", ImVec2(half, 0))) turn(90); + if (ImGui::Button("Flip across", ImVec2(half, 0))) layoutApply([](glm::vec2 p, glm::vec2 m) { return glm::vec2(2 * m.x - p.x, p.y); }); + ImGui::SameLine(); + if (ImGui::Button("Flip up-down", ImVec2(half, 0))) layoutApply([](glm::vec2 p, glm::vec2 m) { return glm::vec2(p.x, 2 * m.y - p.y); }); + if (ImGui::Button("Bigger", ImVec2(half, 0))) layoutApply([](glm::vec2 p, glm::vec2 m) { return m + (p - m) * 1.25f; }); + ImGui::SameLine(); + if (ImGui::Button("Smaller", ImVec2(half, 0))) layoutApply([](glm::vec2 p, glm::vec2 m) { return m + (p - m) * 0.8f; }); + if (ImGui::Button("Straighten", ImVec2(w, 0))) layoutStraighten(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Turns it so its longest edge is level or upright"); + } + void layoutProperties(float w) { + Object* o = selected_.empty() ? nullptr : find(active_); + if (!o) return void(ImGui::TextWrapped("Click an object: its picture's layout shows in the Picture panel.")); + ImGui::TextColored(kAccentText, "%s", o->name.c_str()); + if (!o->picture || !o->uvsFit()) { + if (ImGui::Button("Lay it out", ImVec2(w, 0))) readyToPaint(o->id); + return; + } + const auto islands = uvIslands(o->mesh, o->uvs); + ImGui::TextDisabled("%zu frames, %zu faces, picture %d x %d", islands.size(), o->mesh.faces.size(), o->picture->w, o->picture->h); + if (ImGui::Button("Pick all (Ctrl+A)", ImVec2(w, 0))) layoutSelectAll(); + if (ImGui::Button("Lay it all out again", ImVec2(w, 0))) layoutAgain(*o); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Fry3DX lays it out by itself again (what's painted carried over, if it stays on the model)"); + ImGui::Spacing(); + ImGui::TextWrapped("Each frame is a part of the model laid flat on its picture. Make a frame bigger to give that part more detail, " + "turn it to run a pattern the way you want, or move corners to bend it."); + } + + // ───────────────────────────── the Print tab ───────────────────────────── + + glm::vec3 bedSize() const { // (metres) + const int i = std::clamp(fry_printer.i(), 0, int(std::size(kPrinters)) - 1); + const PrinterInfo& p = kPrinters[i]; + return p.x > 0 ? glm::vec3(p.x, p.y, p.z) * 0.001f : glm::vec3(fry_bed_x.f(), fry_bed_y.f(), fry_bed_z.f()) * 0.001f; + } + const PrintInfo& printInfo(const Object& o) { + PrintInfo& p = printInfos_[o.id]; + uint64_t key = versions_[o.id] * 1000003ull + uint64_t(overhangDeg_ * 10); + for (float f : {o.position.x, o.position.y, o.position.z, o.rotation.x, o.rotation.y, o.rotation.z, o.rotation.w, o.scale.x, o.scale.y, o.scale.z}) { + uint32_t b; + std::memcpy(&b, &f, 4); + key = (key ^ b) * 1099511628211ull; + } + if (p.key == key) return p; + p.key = key; + p.world = o.hasFinish() ? finishedMesh(o) : o.mesh; + transform(p.world, objectMatrix(o)); + p.lo = boundsMin(p.world), p.hi = boundsMax(p.world); + p.check = checkForPrint(p.world, overhangDeg_, 0.0f); + // Overhangs: those faces again a hair above the surface, to show red. + keel::KmdlModel km; + keel::KmdlMaterial mat{}; + mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = mat.baseColor[3] = 1.0f; + km.materials.push_back(mat); + for (size_t f = 0; f < p.world.faces.size(); ++f) { + if (!overhangs(p.world, int(f), overhangDeg_, 0.0f)) continue; + const glm::vec3 n = faceNormal(p.world, int(f)); + for (const auto& t : triangulate(p.world, int(f))) + for (int k : t) { + const glm::vec3 q = p.world.verts[size_t(k)] + n * 0.0004f; + km.indices.push_back(uint32_t(km.vertices.size())); + km.vertices.push_back({{q.x, q.y, q.z}, {n.x, n.y, n.z}, {0, 0}, {0, 0}}); + } + } + p.hasOverhang = !km.indices.empty(); + if (p.hasOverhang) { + km.submeshes.push_back({0, uint32_t(km.indices.size()), 0}); + p.overhang = r_->updateModel(std::format("fry:overhang:{}", o.id), km); + } + return p; + } + std::vector printTargets() { // (what's picked, or everything shown) + std::vector out; + for (Object& o : scene_.objects) + if (o.visible && (selected_.empty() || selected_.count(o.id))) out.push_back(&o); + return out; + } + void drawPrintBed(keel::Renderer& r, const keel::ModelLighting& light) { + const glm::vec3 b = bedSize(); + const glm::vec3 lo(-b.x * 0.5f, -b.y * 0.5f, 0.0f), hi(b.x * 0.5f, b.y * 0.5f, b.z); + const glm::vec4 plate(0.1f, 0.1f, 0.12f, 1.0f); + if (bedFor_ != b) { + bedFor_ = b; + keel::KmdlModel km; + keel::KmdlMaterial mat{}; + mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = mat.baseColor[3] = 1.0f; + km.materials.push_back(mat); + for (glm::vec2 c : {glm::vec2(lo.x, lo.y), glm::vec2(hi.x, lo.y), glm::vec2(hi.x, hi.y), glm::vec2(lo.x, hi.y)}) km.vertices.push_back({{c.x, c.y, -0.0006f}, {0, 0, 1}, {0, 0}, {0, 0}}); + km.indices = {0, 1, 2, 0, 2, 3}; + km.submeshes.push_back({0, 6, 0}); + bed_ = r.updateModel("fry:bed", km); + } + r.drawModel(bed_, glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)), plate, nullptr, &light, false); + // Its outline, and the space the printer reaches (fainter). + const glm::vec3 c[8] = {{lo.x, lo.y, 0}, {hi.x, lo.y, 0}, {hi.x, hi.y, 0}, {lo.x, hi.y, 0}, {lo.x, lo.y, hi.z}, {hi.x, lo.y, hi.z}, {hi.x, hi.y, hi.z}, {lo.x, hi.y, hi.z}}; + const int e[12][2] = {{0, 1}, {1, 2}, {2, 3}, {3, 0}, {4, 5}, {5, 6}, {6, 7}, {7, 4}, {0, 4}, {1, 5}, {2, 6}, {3, 7}}; + bool fits = true; + for (Object* o : printTargets()) { + const PrintInfo& p = printInfo(*o); + fits &= glm::all(glm::greaterThanEqual(p.lo, lo - 1e-4f)) && glm::all(glm::lessThanEqual(p.hi, hi + 1e-4f)); + } + const glm::vec4 col = fits ? glm::vec4(0.9f, 0.9f, 0.95f, 0.5f) : glm::vec4(1.0f, 0.3f, 0.2f, 0.9f); + for (int i = 0; i < 12; ++i) + r.drawStreak(c[e[i][0]] * kUnits, c[e[i][1]] * kUnits, pixelSize(target_) * (i < 4 ? 2.0f : 1.0f) * kUnits, i < 4 ? col : col * glm::vec4(1, 1, 1, 0.5f), + keel::kWhiteTexture); + if (showOverhangs_) { + const keel::ModelLighting flat{{glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f)}, 0.0f}; + for (const Object& o : scene_.objects) + if (o.visible) + if (const PrintInfo& p = printInfo(o); p.hasOverhang) + r.drawModel(p.overhang, glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)), glm::vec4(0.95f, 0.15f, 0.1f, 0.55f), nullptr, &flat, false); + } + } + void putOnBed() { + const auto targets = printTargets(); + if (targets.empty()) return; + pushUndo(); + for (Object* o : targets) o->position.z -= printInfo(*o).lo.z, touched(o->id); + say("Put on the bed"); + } + void middleOfBed() { + const auto targets = printTargets(); + if (targets.empty()) return; + glm::vec3 lo(1e30f), hi(-1e30f); + for (Object* o : targets) lo = glm::min(lo, printInfo(*o).lo), hi = glm::max(hi, printInfo(*o).hi); + pushUndo(); + const glm::vec2 d = -(glm::vec2(lo) + glm::vec2(hi)) * 0.5f; + for (Object* o : targets) o->position += glm::vec3(d, 0.0f), touched(o->id); + } + // Everything picked made `mm` big along `axis`, keeping its shape, standing where it stood. + void printResize(int axis, float mm) { + const auto targets = printTargets(); + glm::vec3 lo(1e30f), hi(-1e30f); + for (Object* o : targets) lo = glm::min(lo, printInfo(*o).lo), hi = glm::max(hi, printInfo(*o).hi); + const float now_ = (hi - lo)[axis] * 1000.0f; + if (targets.empty() || now_ < 1e-3f || mm <= 0.01f) return; + const float k = mm / now_; + const glm::vec3 pivot((lo.x + hi.x) * 0.5f, (lo.y + hi.y) * 0.5f, lo.z); + for (Object* o : targets) o->position = pivot + (o->position - pivot) * k, o->scale *= k, touched(o->id); + } + void printToolbox(float w) { + ImGui::Spacing(); + if (wideButton("##measure", Icon::Ruler, "Measure", printTool_ == PrintTool::Measure, w, "Click two points to see how far apart they are (M)")) + printTool_ = printTool_ == PrintTool::Measure ? PrintTool::None : PrintTool::Measure, measure_.clear(); + if (wideButton("##layflat", Icon::LayFlat, "Lay flat", printTool_ == PrintTool::LayFlat, w, "Click the face it should stand on (L)")) + printTool_ = printTool_ == PrintTool::LayFlat ? PrintTool::None : PrintTool::LayFlat; + if (wideButton("##bed", Icon::Move, "Put on the bed", false, w, "Down (or up) so it stands on the printer's plate (B)")) putOnBed(); + if (wideButton("##mid", Icon::Box, "Middle of the bed", false, w, "To the middle of the printer's plate")) middleOfBed(); + ImGui::Spacing(); + if (wideButton("##3mf", Icon::Print, "Save for the slicer", false, w, "3MF: opens in PrusaSlicer, Bambu Studio, OrcaSlicer, Cura")) dialog(8); + if (wideButton("##stl", Icon::Print, "Save as STL", false, w, "STL, in millimetres")) dialog(9); + } + void printOptions(float w) { + if (printTool_ == PrintTool::Measure) { + ImGui::SeparatorText("Measure"); + ImGui::Checkbox("Catch corners", &measureSnap_); + if (measure_.size() == 2) { + const glm::vec3 d = (measure_[1] - measure_[0]) * 1000.0f; + ImGui::Text("%.2f mm", glm::length(d)); + ImGui::TextDisabled("across %.2f deep %.2f up %.2f", std::abs(d.x), std::abs(d.y), std::abs(d.z)); + } else + ImGui::TextWrapped("Click a point on the model, then another."); + if (ImGui::Button("Clear", ImVec2(w, 0))) measure_.clear(); + } else { + ImGui::SeparatorText("Lay flat"); + ImGui::TextWrapped("Click the face it should stand on: it turns so that face is down, and sits on the bed. A big flat face down prints best, with least support."); + } + } + void printViewport(const ImGuiIO& io) { + if (!mouseInView(io) || !ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return; + if (printTool_ == PrintTool::Measure) { + if (const auto p = measurePoint(io.MousePos)) { + if (measure_.size() >= 2) measure_.clear(); + measure_.push_back(*p); + if (measure_.size() == 2) say(std::format("{:.2f} mm", glm::distance(measure_[0], measure_[1]) * 1000.0f)); + } + return; + } + const Hit h = pickObject(mouseRay(io.MousePos)); + Object* o = h.object ? find(h.object) : nullptr; + if (!o) return; + const Mesh& m = printInfo(*o).world; + if (h.face < 0 || h.face >= int(m.faces.size())) return; + pushUndo(); + const glm::vec3 n = faceNormal(m, h.face); + o->rotation = glm::normalize(glm::rotation(n, glm::vec3(0, 0, -1)) * o->rotation); + touched(o->id); + o->position.z -= printInfo(*o).lo.z; + touched(o->id); + selected_ = {o->id}, active_ = o->id; + say("Laid flat on that face"); + } + // Where the mouse is on the model (a corner if near one), or on the bed. + std::optional measurePoint(ImVec2 mouse) { + const Ray ray = mouseRay(mouse); + const Hit h = pickObject(ray); + if (h.object) { + glm::vec3 p = ray.origin + ray.dir * h.t; + if (measureSnap_) + if (const Object* o = find(h.object)) { + float best = 12.0f; + for (const glm::vec3& v : printInfo(*o).world.verts) { + ImVec2 q; + if (project(v, q)) + if (const float d = std::hypot(q.x - mouse.x, q.y - mouse.y); d < best) best = d, p = v; + } + } + return p; + } + if (std::abs(ray.dir.z) > 1e-5f) + if (const float t = -ray.origin.z / ray.dir.z; t > 0) return ray.origin + ray.dir * t; + return std::nullopt; + } + void printOverlay(ImDrawList* dl, const ImGuiIO& io) { + if (printTool_ != PrintTool::Measure) return; + std::vector pts = measure_; + if (pts.size() == 1 && mouseInView(io)) + if (auto p = measurePoint(io.MousePos)) pts.push_back(*p); + ImVec2 q[2]; + for (size_t i = 0; i < pts.size(); ++i) + if (project(pts[i], q[i])) dl->AddCircleFilled(q[i], 5, kAccent); + if (pts.size() == 2 && project(pts[0], q[0]) && project(pts[1], q[1])) { + dl->AddLine(q[0], q[1], kAccent, 2.0f); + const std::string t = std::format("{:.2f} mm", glm::distance(pts[0], pts[1]) * 1000.0f); + const ImVec2 mid((q[0].x + q[1].x) * 0.5f, (q[0].y + q[1].y) * 0.5f), ts = ImGui::CalcTextSize(t.c_str()); + dl->AddRectFilled(ImVec2(mid.x - ts.x * 0.5f - 6, mid.y - ts.y - 10), ImVec2(mid.x + ts.x * 0.5f + 6, mid.y - 4), IM_COL32(20, 20, 24, 230), 4.0f); + dl->AddText(ImVec2(mid.x - ts.x * 0.5f, mid.y - ts.y - 7), IM_COL32(255, 255, 255, 255), t.c_str()); + } + } + void printProperties(float w) { + auto good = [](const char* t) { ImGui::TextColored(ImVec4(0.45f, 0.85f, 0.4f, 1), "%s", t); }; + auto bad = [](const std::string& t) { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.45f, 0.3f, 1)), ImGui::TextWrapped("%s", t.c_str()), ImGui::PopStyleColor(); }; + ImGui::SeparatorText("Printer"); + int printer = std::clamp(fry_printer.i(), 0, int(std::size(kPrinters)) - 1); + ImGui::SetNextItemWidth(w); + if (ImGui::BeginCombo("##printer", kPrinters[printer].name)) { + for (int i = 0; i < int(std::size(kPrinters)); ++i) + if (ImGui::Selectable(kPrinters[i].name, i == printer)) keel::execute(std::format("fry_printer {}", i)); + ImGui::EndCombo(); + } + const glm::vec3 bed = bedSize() * 1000.0f; + if (kPrinters[printer].x <= 0) { + float b[3] = {bed.x, bed.y, bed.z}; + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat3("##bed", b, 1.0f, 50.0f, 2000.0f, "%.0f mm")) + keel::execute(std::format("fry_bed_x {}; fry_bed_y {}; fry_bed_z {}", b[0], b[1], b[2])); + } else + ImGui::TextDisabled("Plate %.0f x %.0f mm, %.0f mm high", bed.x, bed.y, bed.z); + + const auto targets = printTargets(); + if (targets.empty()) return void(ImGui::TextWrapped("Nothing to print: add something in the Model tab.")); + glm::vec3 lo(1e30f), hi(-1e30f); + PrintCheck all; + for (Object* o : targets) { + const PrintInfo& p = printInfo(*o); + lo = glm::min(lo, p.lo), hi = glm::max(hi, p.hi); + all.openEdges += p.check.openEdges, all.crowdedEdges += p.check.crowdedEdges, all.flippedEdges += p.check.flippedEdges; + all.tinyFaces += p.check.tinyFaces, all.pieces += p.check.pieces, all.insideOut |= p.check.insideOut; + all.volume += std::abs(p.check.volume), all.area += p.check.area, all.overhangArea += p.check.overhangArea; + } + const glm::vec3 size = (hi - lo) * 1000.0f; + ImGui::SeparatorText(selected_.empty() ? "Everything" : targets.size() == 1 ? targets[0]->name.c_str() : "What's picked"); + // Its size, typed in millimetres (keeps its shape). + const char* axes[] = {"across", "deep", "high"}; + for (int a = 0; a < 3; ++a) { + float v = size[a]; + ImGui::SetNextItemWidth(w * 0.62f); + ImGui::PushID(a); + if (ImGui::DragFloat("##size", &v, 0.5f, 0.1f, 100000.0f, "%.2f mm")) { + if (ImGui::IsItemActivated() || !sizing_) pushUndo(), sizing_ = true; + printResize(a, v); + } + if (ImGui::IsItemDeactivated()) sizing_ = false; + ImGui::PopID(); + ImGui::SameLine(); + ImGui::TextDisabled("%s", axes[a]); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Type a size: it keeps its shape"); + const glm::vec3 b = bedSize(); + const bool fits = size.x <= bed.x + 0.05f && size.y <= bed.y + 0.05f && size.z <= bed.z + 0.05f; + const bool onPlate = lo.x >= -b.x * 0.5f - 1e-4f && hi.x <= b.x * 0.5f + 1e-4f && lo.y >= -b.y * 0.5f - 1e-4f && hi.y <= b.y * 0.5f + 1e-4f && hi.z <= b.z + 1e-4f; + if (!fits) { + const float k = std::min({bed.x / std::max(size.x, 1e-3f), bed.y / std::max(size.y, 1e-3f), bed.z / std::max(size.z, 1e-3f)}); + bad(std::format("Too big for this printer: it would fit at {:.0f}% of this size (or in parts).", std::floor(k * 1000) / 10)); + } else if (!onPlate) { + bad("It fits, but it's off the plate."); + if (ImGui::Button("Middle of the bed", ImVec2(w, 0))) middleOfBed(); + } else + good("Fits on the plate"); + if (std::abs(lo.z) > 1e-4f) { + bad(lo.z > 0 ? std::format("It floats {:.2f} mm above the bed.", lo.z * 1000) : std::format("It goes {:.2f} mm into the bed (that part won't print).", -lo.z * 1000)); + if (ImGui::Button("Put it on the bed (B)", ImVec2(w, 0))) putOnBed(); + } + + ImGui::SeparatorText("Ready to print?"); + if (all.openEdges) { + bad(std::format("Not closed: {} edges have a face on one side only (holes). A slicer may fill them wrongly or not print it.", all.openEdges)); + if (ImGui::Button("Close the holes", ImVec2(w, 0))) { + pushUndo(); + int n = 0; + for (Object* o : targets) n += closeHoles(o->mesh), touched(o->id); + say(std::format("Closed {} hole{}", n, n == 1 ? "" : "s")); + } + } else + good("Closed all round"); + if (all.flippedEdges || all.insideOut) { + bad("Some faces face inwards: the slicer can't tell inside from out."); + if (ImGui::Button("Turn them the right way", ImVec2(w, 0))) { + pushUndo(); + int n = 0; + for (Object* o : targets) n += faceOutward(o->mesh), touched(o->id); + say(std::format("Turned {} face{}", n, n == 1 ? "" : "s")); + } + } else + good("Faces all face outwards"); + if (all.crowdedEdges) bad(std::format("{} edges have more than two faces on them (shapes stuck together there): separate or rebuild that part.", all.crowdedEdges)); + if (all.tinyFaces) bad(std::format("{} faces have no size: Merge by distance in the Model tab cleans them up.", all.tinyFaces)); + if (all.pieces > int(targets.size())) ImGui::TextDisabled("%d separate pieces", all.pieces); + const float hang = all.overhangArea * 1e4f; + if (hang > 0.01f) bad(std::format("{:.1f} cm2 hangs over the air (red): it needs supports, or turn it (Lay flat).", hang)); + else good("Nothing hangs over the air"); + ImGui::Checkbox("Show overhangs in red", &showOverhangs_); + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##hang", &overhangDeg_, 20.0f, 80.0f, "steeper than %.0f degrees"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("How far from upright a face can lean over before it needs support (45 for most printers)"); + + ImGui::SeparatorText("Material"); + ImGui::SetNextItemWidth(w); + ImGui::Combo("##material", &material_, [](void*, int i) { return kMaterials[i].name; }, nullptr, int(std::size(kMaterials))); + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##infill", &infill_, 0.0f, 100.0f, "infill %.0f%%"); + // (walls about 1.2 mm thick all round, the inside filled as much as the infill says) + const float vol = all.volume * 1e6f, walls = std::min(vol, all.area * 1e4f * 0.12f); + const float used = walls + (vol - walls) * infill_ / 100.0f, grams = used * kMaterials[material_].density; + ImGui::Text("Holds %.1f cm3", vol); + ImGui::Text("About %.0f g, %.1f m of 1.75 mm filament", grams, used / (3.14159f * 0.0875f * 0.0875f) / 100.0f); + ImGui::TextDisabled("(a guess: the slicer knows best)"); + } + + // ───────────────────────────── layers: folders and going to things ───────────────────────────── + + Folder* findFolder(uint32_t id) { + for (Folder& f : scene_.folders) + if (f.id == id) return &f; + return nullptr; + } + bool inFolder(uint32_t folder, uint32_t within) { // (folder is within, or inside it somewhere) + for (int n = 0; folder && n < 256; ++n) { + if (folder == within) return true; + const Folder* f = findFolder(folder); + folder = f ? f->parent : 0; + } + return false; + } + std::set objectsIn(uint32_t folder) { + std::set out; + for (const Object& o : scene_.objects) + if (inFolder(o.folder, folder)) out.insert(o.id); + return out; + } + // The camera brought round to these (as framing them, but keeping the way it looks). + void goTo(const std::set& ids) { + glm::vec3 lo(1e30f), hi(-1e30f); + for (const Object& o : scene_.objects) + if (ids.count(o.id)) { + const glm::mat4 m = objectMatrix(o); + for (const glm::vec3& v : o.mesh.verts) { + const glm::vec3 w(m * glm::vec4(v, 1.0f)); + lo = glm::min(lo, w), hi = glm::max(hi, w); + } + } + if (lo.x > hi.x) return; + frameBox(lo, hi); + } + // The picked objects into a new folder (Ctrl+G): a group, picked and moved together. + void groupSelected() { + if (mode_ == Mode::Edit) leaveEdit(); + if (selected_.empty()) return say("Pick the objects to put in a folder first"); + pushUndo(); + Folder f; + f.id = scene_.nextId++; + int n = 1; + while (std::any_of(scene_.folders.begin(), scene_.folders.end(), [&](const Folder& g) { return g.name == std::format("Group {}", n); })) ++n; + f.name = std::format("Group {}", n); + if (const Object* a = find(active_)) f.parent = a->folder; // (where the last picked was) + for (Object& o : scene_.objects) + if (selected_.count(o.id)) o.folder = f.id; + scene_.folders.push_back(f); + renamingFolder_ = f.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f.name.c_str()); + say(f.name + ": click it to pick all of it; type a name"); + } + // Ungrouped: what's inside goes up a level, the folder goes. + void ungroup(uint32_t id) { + const Folder* f = findFolder(id); + if (!f) return; + pushUndo(); + const uint32_t up = f->parent; + for (Object& o : scene_.objects) + if (o.folder == id) o.folder = up; + for (Folder& g : scene_.folders) + if (g.parent == id) g.parent = up; + std::erase_if(scene_.folders, [&](const Folder& g) { return g.id == id; }); + } + void deleteFolder(uint32_t id) { + pushUndo(); + const auto ids = objectsIn(id); + std::erase_if(scene_.objects, [&](const Object& o) { return ids.count(o.id) > 0; }); + std::erase_if(scene_.folders, [&](const Folder& g) { return inFolder(g.id, id); }); + for (uint32_t i : ids) selected_.erase(i); + } + // A folder's eye or lock: all in it the same. + void showFolder(Folder& f, bool on) { + pushUndo(); + for (Folder& g : scene_.folders) + if (inFolder(g.id, f.id)) g.visible = on; + for (Object& o : scene_.objects) + if (inFolder(o.folder, f.id)) o.visible = on; + } + void lockFolder(Folder& f, bool on) { + pushUndo(); + for (Folder& g : scene_.folders) + if (inFolder(g.id, f.id)) g.locked = on; + for (Object& o : scene_.objects) + if (inFolder(o.folder, f.id)) o.locked = on; + } + void moveToFolder(const std::set& ids, uint32_t folder) { + pushUndo(); + for (Object& o : scene_.objects) + if (ids.count(o.id)) o.folder = folder; + } + // Only these shown (again: everything shown). + void showOnly(const std::set& ids) { + pushUndo(); + const bool already = std::all_of(scene_.objects.begin(), scene_.objects.end(), [&](const Object& o) { return o.visible == (ids.count(o.id) > 0); }); + for (Object& o : scene_.objects) o.visible = already || ids.count(o.id); + for (Folder& f : scene_.folders) f.visible = true; + say(already ? "Everything shown again" : "Only that shown (do it again to show everything)"); + } + void folderMenu(uint32_t into, const std::set& ids, int depth = 0) { // (Move to folder: the folders inside `into`) + for (const Folder& f : scene_.folders) + if (f.parent == into) { + const std::string label = std::string(size_t(depth) * 3, ' ') + f.name; + if (ImGui::MenuItem(label.c_str())) moveToFolder(ids, f.id); + folderMenu(f.id, ids, depth + 1); + } + } + // What a right-click on an object (or the picked ones) offers. + void objectMenu(Object& o) { + const std::set ids = selected_.count(o.id) ? selected_ : std::set{o.id}; + if (ImGui::MenuItem("Go to it", "F")) goTo(ids); + if (ImGui::MenuItem("Rename")) renaming_ = o.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", o.name.c_str()); + if (ImGui::MenuItem("Duplicate", "Ctrl+D")) selected_ = ids, active_ = o.id, duplicateSelected(); + if (ImGui::MenuItem("Show only this (again: all)")) showOnly(ids); + if (ImGui::BeginMenu("Move to folder")) { + if (ImGui::MenuItem("(the top, no folder)")) moveToFolder(ids, 0); + folderMenu(0, ids); + ImGui::Separator(); + if (ImGui::MenuItem("A new folder", "Ctrl+G")) selected_ = ids, active_ = o.id, groupSelected(); + ImGui::EndMenu(); + } + ImGui::Separator(); + if (ImGui::MenuItem("Delete", "Del")) selected_ = ids, deleteSelected(); + } + // The ⌖ button at the end of a row: brings the camera to it. + bool goButton(const char* tip) { + const ImVec2 at = ImGui::GetCursorScreenPos(); + const float h = ImGui::GetFrameHeight() - 4; + const bool pressed = ImGui::InvisibleButton("##go", ImVec2(h, h)); + const ImU32 col = ImGui::IsItemHovered() ? kHot : IM_COL32(200, 200, 205, 200); + const ImVec2 c(at.x + h * 0.5f, at.y + h * 0.5f); + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddCircle(c, h * 0.3f, col, 16, 1.5f); + dl->AddLine(ImVec2(c.x - h * 0.48f, c.y), ImVec2(c.x - h * 0.16f, c.y), col, 1.5f), dl->AddLine(ImVec2(c.x + h * 0.16f, c.y), ImVec2(c.x + h * 0.48f, c.y), col, 1.5f); + dl->AddLine(ImVec2(c.x, c.y - h * 0.48f), ImVec2(c.x, c.y - h * 0.16f), col, 1.5f), dl->AddLine(ImVec2(c.x, c.y + h * 0.16f), ImVec2(c.x, c.y + h * 0.48f), col, 1.5f); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); + return pressed; + } + // Rows for what's in a folder (0: the top): its folders, then its objects. + void layerRows(uint32_t parent, int depth) { + const float indent = float(depth) * 14.0f; + // (copies of the ids: rows may change the lists) + std::vector folders, objects; + for (const Folder& f : scene_.folders) + if (f.parent == parent) folders.push_back(f.id); + for (const Object& o : scene_.objects) + if (o.folder == parent) objects.push_back(o.id); + for (uint32_t id : folders) { + Folder* f = findFolder(id); + if (!f) continue; + const auto inside = objectsIn(id); + ImGui::PushID(int(id) + 1000000); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + if (ImGui::SmallButton(f->visible ? "o" : "-")) showFolder(*f, !f->visible); + if (ImGui::IsItemHovered()) ImGui::SetTooltip(f->visible ? "Hide all in it" : "Show all in it"); + ImGui::TableNextColumn(); + if (ImGui::SmallButton(f->locked ? "L" : " ")) lockFolder(*f, !f->locked); + if (ImGui::IsItemHovered()) ImGui::SetTooltip(f->locked ? "Unlock all in it" : "Lock all in it"); + ImGui::TableNextColumn(); + ImGui::Indent(indent + 1); + if (ImGui::ArrowButton("##open", f->open ? ImGuiDir_Down : ImGuiDir_Right)) f->open = !f->open; + ImGui::SameLine(0, 4); + const bool picked = !inside.empty() && std::all_of(inside.begin(), inside.end(), [&](uint32_t i) { return selected_.count(i) > 0; }); + if (renamingFolder_ == id) { + ImGui::SetNextItemWidth(-1); + if (!ImGui::IsAnyItemActive()) ImGui::SetKeyboardFocusHere(); + if (ImGui::InputText("##rename", renameBuf_, sizeof renameBuf_, ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll)) { + if (renameBuf_[0]) f->name = renameBuf_; + renamingFolder_ = 0, dirty_ = true; + } + if (ImGui::IsItemDeactivated()) renamingFolder_ = 0; + } else { + ImGui::PushStyleColor(ImGuiCol_Text, kAccentText); + const std::string label = std::format("{} ({})", f->name, inside.size()); + if (ImGui::Selectable(label.c_str(), picked, ImGuiSelectableFlags_AllowDoubleClick)) { // a folder is a group: all of it picked + if (mode_ == Mode::Edit) leaveEdit(); + if (ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift) selected_.insert(inside.begin(), inside.end()); + else selected_ = inside; + if (!inside.empty()) active_ = *inside.rbegin(); + activeRef_ = 0; + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) renamingFolder_ = id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f->name.c_str()); + } + ImGui::PopStyleColor(); + if (ImGui::BeginDragDropSource()) { + ImGui::SetDragDropPayload("FRY_FOLDER", &id, sizeof id); + ImGui::Text("%s", f->name.c_str()); + ImGui::EndDragDropSource(); + } + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_OBJECT")) { + const uint32_t oid = *static_cast(p->Data); + moveToFolder(selected_.count(oid) ? selected_ : std::set{oid}, id); + } + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_FOLDER")) { + const uint32_t fid = *static_cast(p->Data); + if (Folder* g = findFolder(fid); g && !inFolder(id, fid)) pushUndo(), g->parent = id; // (not into itself) + } + ImGui::EndDragDropTarget(); + } + if (ImGui::IsItemHovered() && !ImGui::IsMouseDragging(ImGuiMouseButton_Left)) ImGui::SetTooltip("Click: pick all of it (a group). Double-click: rename. Drag things onto it."); + if (ImGui::BeginPopupContextItem("folder")) { + if (ImGui::MenuItem("Go to it")) goTo(inside); + if (ImGui::MenuItem("Pick all of it")) selected_ = inside; + if (ImGui::MenuItem("Rename")) renamingFolder_ = id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f->name.c_str()); + if (ImGui::MenuItem("Show only this (again: all)")) showOnly(inside); + if (ImGui::MenuItem("A folder inside it")) { + pushUndo(); + Folder g; + g.id = scene_.nextId++, g.name = "Folder", g.parent = id; + scene_.folders.push_back(g); + f = findFolder(id), f->open = true; + renamingFolder_ = g.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", g.name.c_str()); + } + if (ImGui::MenuItem("Take it to the top") && f->parent) pushUndo(), f->parent = 0; + ImGui::Separator(); + if (ImGui::MenuItem("Ungroup (keep what's in it)")) ungroup(id); + if (ImGui::MenuItem("Delete it and all in it")) deleteFolder(id); + ImGui::EndPopup(); + } + } + ImGui::Unindent(indent + 1); + ImGui::TableNextColumn(); + if (goButton("Go to it: the camera comes round to all of it")) goTo(inside); + ImGui::PopID(); + if ((f = findFolder(id)) && f->open) layerRows(id, depth + 1); + } + for (uint32_t id : objects) { + Object* op = find(id); + if (!op) continue; + Object& o = *op; + ImGui::PushID(int(o.id)); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + if (ImGui::SmallButton(o.visible ? "o" : "-")) o.visible = !o.visible, dirty_ = true; + if (ImGui::IsItemHovered()) ImGui::SetTooltip(o.visible ? "Shown (click to hide)" : "Hidden (click to show)"); + ImGui::TableNextColumn(); + if (ImGui::SmallButton(o.locked ? "L" : " ")) o.locked = !o.locked, dirty_ = true; + if (ImGui::IsItemHovered()) ImGui::SetTooltip(o.locked ? "Locked: can't be moved or changed" : "Click to lock"); + ImGui::TableNextColumn(); + ImGui::Indent(indent + 1); + if (renaming_ == o.id) { + ImGui::SetNextItemWidth(-1); + if (!ImGui::IsAnyItemActive()) ImGui::SetKeyboardFocusHere(); + if (ImGui::InputText("##rename", renameBuf_, sizeof renameBuf_, ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll)) { + pushUndo(); + if (renameBuf_[0]) o.name = renameBuf_; + renaming_ = 0; + } + if (ImGui::IsItemDeactivated()) renaming_ = 0; + } else { + ImGui::PushStyleColor(ImGuiCol_Text, o.visible ? ImGui::GetStyleColorVec4(ImGuiCol_Text) : ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + if (ImGui::Selectable(o.name.c_str(), selected_.count(o.id) > 0, ImGuiSelectableFlags_AllowDoubleClick)) { + if (mode_ == Mode::Edit && o.id != active_) leaveEdit(); + if (ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift) selected_.count(o.id) ? (void)selected_.erase(o.id) : (void)selected_.insert(o.id); + else selected_ = {o.id}; + active_ = o.id, activeRef_ = 0; + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) renaming_ = o.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", o.name.c_str()); + } + ImGui::PopStyleColor(); + if (ImGui::BeginDragDropSource()) { + ImGui::SetDragDropPayload("FRY_OBJECT", &id, sizeof id); + ImGui::Text("%s", selected_.count(id) && selected_.size() > 1 ? std::format("{} objects", selected_.size()).c_str() : o.name.c_str()); + ImGui::EndDragDropSource(); + } + if (ImGui::IsItemHovered() && !ImGui::IsMouseDragging(ImGuiMouseButton_Left)) ImGui::SetTooltip("Double-click to rename; drag onto a folder; right-click for more"); + if (ImGui::BeginPopupContextItem("object")) { + objectMenu(o); + ImGui::EndPopup(); + } + } + ImGui::Unindent(indent + 1); + ImGui::TableNextColumn(); + if (goButton("Go to it: the camera comes round to it")) goTo({id}); + ImGui::PopID(); + } + } + + // ───────────────────────────── combining, copies, thickness ───────────────────────────── + + // The last picked object with the others added, taken out of it, or only where they overlap + // kept; the others go. + void combineSelected(Combine op) { + if (mode_ == Mode::Edit) leaveEdit(); + Object* a = find(active_); + if (!a || selected_.size() < 2) return say("Pick two or more objects: the last picked is kept, the others are added to it / taken out of it"); + pushUndo(); + Mesh result = a->hasFinish() ? finishedMesh(*a) : a->mesh; + const glm::mat4 into = glm::inverse(objectMatrix(*a)); + int used = 0; + for (const Object& b : scene_.objects) { + if (b.id == a->id || !selected_.count(b.id)) continue; + Mesh m = b.hasFinish() ? finishedMesh(b) : b.mesh; + transform(m, into * objectMatrix(b)); + if (!combine(result, m, op)) { + undo(); + redo_.clear(); + return say(op == Combine::Overlap ? "They don't overlap: nothing would be left" : "That didn't leave anything"); + } + ++used; + } + a = find(active_); + a->mesh = std::move(result); + a->mirror = 0, a->round = 0, a->smoothLevels = 0; + const std::set others = [&] { + std::set s = selected_; + s.erase(a->id); + return s; + }(); + changedShape(*a); + std::erase_if(scene_.objects, [&](const Object& o) { return others.count(o.id) > 0; }); + selected_ = {active_}; + static const char* done[] = {"added to it", "taken out of it", "overlapping kept"}; + say(std::format("{}: {} other{} {} ({} faces)", find(active_)->name, used, used == 1 ? "" : "s", done[int(op)], find(active_)->mesh.faces.size())); + } + void makeCopies() { + if (mode_ == Mode::Edit) leaveEdit(); + if (selected_.empty()) return say("Pick what to copy first"); + pushUndo(); + const CopiesSetting& c = copies_; + std::set made = selected_; + const std::vector originals = [&] { + std::vector v; + for (const Object& o : scene_.objects) + if (selected_.count(o.id)) v.push_back(o); + return v; + }(); + const glm::vec3 axis = c.axis == 0 ? glm::vec3(1, 0, 0) : c.axis == 1 ? glm::vec3(0, 1, 0) : glm::vec3(0, 0, 1); + for (const Object& o : originals) { + Object* joinInto = c.joined ? find(o.id) : nullptr; + for (int k = 1; k <= c.count; ++k) { + glm::mat4 T(1.0f); + if (c.round) { + const float a = glm::radians(c.angle) * float(k) / float(c.angle >= 359.99f ? c.count + 1 : c.count); + T = glm::translate(glm::mat4(1.0f), c.centre) * glm::rotate(glm::mat4(1.0f), a, axis) * glm::translate(glm::mat4(1.0f), -c.centre); + } else + T = glm::translate(glm::mat4(1.0f), c.step * float(k)); + if (joinInto) { // (into the original's own shape) + Mesh m = o.mesh; + transform(m, glm::inverse(objectMatrix(o)) * T * objectMatrix(o)); + append(joinInto->mesh, m); + continue; + } + Object& n = addObject(o.name, o.mesh); + const uint32_t id = n.id; + const std::string name = n.name; + n = o, n.id = id, n.name = name, n.locked = false; + n.position = glm::vec3(T * glm::vec4(o.position, 1.0f)); + if (c.round) n.rotation = glm::normalize(glm::quat_cast(glm::mat3(T)) * o.rotation); + made.insert(id); + } + if (joinInto) changedShape(*joinInto); + } + selected_ = made; + touchAll(); + say(std::format("{} cop{} of each made{}", c.count, c.count == 1 ? "y" : "ies", c.joined ? ", joined into it" : "")); + } + void copiesWindow() { + if (!showCopies_) return; + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + if (!ImGui::Begin("Make copies", &showCopies_, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End(); + CopiesSetting& c = copies_; + const float w = 280; + ImGui::SetNextItemWidth(w); + ImGui::SliderInt("##count", &c.count, 1, 100, "%d copies"); + int kind = c.round ? 1 : 0; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##kind", &kind, "In a row\0Round in a circle\0")) c.round = kind == 1; + if (!c.round) { + glm::vec3 mm = c.step * 1000.0f; + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat3("##step", &mm.x, 1.0f, -1e6f, 1e6f, "%.1f mm")) c.step = mm / 1000.0f; + ImGui::TextDisabled("each one this far on from the last (X Y Z)"); + } else { + ImGui::SetNextItemWidth(w); + ImGui::Combo("##axis", &c.axis, "Round the X axis\0Round the Y axis\0Round the Z axis (up)\0"); + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##angle", &c.angle, 1.0f, 360.0f, "over %.0f degrees"); + glm::vec3 mm = c.centre * 1000.0f; + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat3("##centre", &mm.x, 1.0f, -1e6f, 1e6f, "%.1f mm")) c.centre = mm / 1000.0f; + ImGui::TextDisabled("round this point (X Y Z)"); + if (ImGui::Button("Round the picked one's middle", ImVec2(w, 0))) + if (const Object* o = find(active_)) c.centre = o->position; + } + ImGui::Checkbox("Joined into one object", &c.joined); + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Button, kAccent); + if (ImGui::Button("Make them", ImVec2(w, 0))) makeCopies(), showCopies_ = false; + ImGui::PopStyleColor(); + ImGui::End(); + } + void thickenActive() { + Object* o = mode_ == Mode::Edit ? editing() : find(active_); + if (!o || selected_.empty()) return say("Pick the object to make thick first"); + pushUndo(); + if (o->hasFinish()) o->mesh = finishedMesh(*o), o->mirror = 0, o->round = 0, o->smoothLevels = 0; + const float t = thickness_ / objectScale(*o); + thicken(o->mesh, thicknessOut_ ? -t : t); + changedShape(*o); + say(std::format("{} made {:.1f} mm thick{}", o->name, thickness_ * 1000, checkForPrint(o->mesh).closed() ? " (closed all round)" : "")); + } + void thickenWindow() { + if (!showThicken_) return; + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + if (!ImGui::Begin("Make it thick", &showThicken_, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End(); + const float w = 260; + float mm = thickness_ * 1000; + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat("##t", &mm, 0.1f, 0.1f, 1e5f, "%.2f mm thick")) thickness_ = mm / 1000; + int out = thicknessOut_ ? 1 : 0; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##dir", &out, "Inwards (a closed shape: hollow)\0Outwards\0")) thicknessOut_ = out == 1; + ImGui::TextWrapped("A flat or open surface gets a back and edges: a solid you can print. A closed shape becomes hollow, walls this thick (to save material or make a box)."); + ImGui::PushStyleColor(ImGuiCol_Button, kAccent); + if (ImGui::Button("Make it thick", ImVec2(w, 0))) thickenActive(), showThicken_ = false; + ImGui::PopStyleColor(); + ImGui::End(); + } + void cleanFlatFaces() { + Object* o = mode_ == Mode::Edit ? editing() : find(active_); + if (!o || selected_.empty()) return say("Pick an object first"); + pushUndo(); + const int n = mergeFlatFaces(o->mesh); + changedShape(*o); + say(n ? std::format("{} faces joined into the flat ones beside them", n) : "Nothing to join: no flat faces side by side"); + } + + // ───────────────────────────── the camera ───────────────────────────── + + glm::vec3 viewDir() const { return forwardOf(yaw_, pitch_); } + float viewHeight() const { return 2.0f * dist_ * std::tan(glm::radians(fov_) * 0.5f); } // metres across the view's height at the target + void setView(View v) { + const float size = viewHeight(); + view_ = v; + switch (v) { + case View::Front: yaw_ = 180, pitch_ = 0; break; // from +X, looking back at the front + case View::Back: yaw_ = 0, pitch_ = 0; break; + case View::Left: yaw_ = -90, pitch_ = 0; break; // from +Y (the model's left side) + case View::Right: yaw_ = 90, pitch_ = 0; break; + case View::Top: yaw_ = 0, pitch_ = 89.9f; break; // front up the screen + case View::Bottom: yaw_ = 0, pitch_ = -89.9f; break; + default: break; + } + // Side views: nearly flat (a narrow lens from far away), the same size on screen. + fov_ = v == View::Perspective ? 50.0f : 2.0f; + dist_ = size / (2.0f * std::tan(glm::radians(fov_) * 0.5f)); + } + void leaveFlatView() { + if (view_ == View::Perspective) return; + const float size = viewHeight(); + view_ = View::Perspective, fov_ = 50.0f; + dist_ = size / (2.0f * std::tan(glm::radians(fov_) * 0.5f)); + } + void frameBox(glm::vec3 lo, glm::vec3 hi) { + if (lo.x > hi.x) return; + target_ = (lo + hi) * 0.5f; + const float radius = std::max(glm::length(hi - lo) * 0.5f, 0.05f); + dist_ = radius * 1.4f / std::tan(glm::radians(fov_) * 0.5f); + } + void frameAll() { + glm::vec3 lo(1e30f), hi(-1e30f); + for (const Object& o : scene_.objects) { + if (!o.visible) continue; + const glm::mat4 m = objectMatrix(o); + for (const glm::vec3& v : o.mesh.verts) { + const glm::vec3 w(m * glm::vec4(v, 1.0f)); + lo = glm::min(lo, w), hi = glm::max(hi, w); + } + } + frameBox(lo, hi); + } + void frameSelected() { + glm::vec3 lo(1e30f), hi(-1e30f); + for (const glm::vec3& w : selectionPoints()) lo = glm::min(lo, w), hi = glm::max(hi, w); + if (lo.x > hi.x) return frameAll(); + frameBox(lo, hi); + } + + bool mouseInView(const ImGuiIO& io) const { + const ImVec2 m = io.MousePos; + return m.x >= viewMin_.x && m.y >= viewMin_.y && m.x < viewMax_.x && m.y < viewMax_.y && !io.WantCaptureMouse; + } + + void orbitBy(ImVec2 d) { + if (std::abs(d.x) + std::abs(d.y) > 0.5f) leaveFlatView(); + yaw_ -= d.x * 0.3f; + pitch_ = std::clamp(pitch_ + d.y * 0.3f, -89.9f, 89.9f); + } + void panBy(ImVec2 d) { + const glm::vec3 f = viewDir(), right = glm::normalize(glm::cross(f, std::abs(f.z) > 0.99f ? glm::vec3(std::cos(glm::radians(yaw_)), std::sin(glm::radians(yaw_)), 0) : glm::vec3(0, 0, 1))); + const glm::vec3 up = glm::cross(right, f); + const float perPixel = viewHeight() / std::max(ImGui::GetIO().DisplaySize.y, 1.0f); + target_ += (-right * d.x + up * d.y) * perPixel; + } + + void updateCamera(ImGuiIO& io) { + const bool inView = mouseInView(io); + // Orbit: right drag. Pan: middle drag (or Shift + right). Zoom: the wheel. + const bool rightDown = ImGui::IsMouseDown(ImGuiMouseButton_Right), midDown = ImGui::IsMouseDown(ImGuiMouseButton_Middle); + if (inView && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) orbiting_ = !io.KeyShift; + if (inView && (ImGui::IsMouseClicked(ImGuiMouseButton_Middle) || (io.KeyShift && ImGui::IsMouseClicked(ImGuiMouseButton_Right)))) panning_ = true, orbiting_ = false; + if (!rightDown) orbiting_ = false; + if (!midDown && !rightDown) panning_ = false; + const ImVec2 d = io.MouseDelta; + if (orbiting_ && (d.x != 0 || d.y != 0)) orbitBy(d); + if (panning_ && (d.x != 0 || d.y != 0)) panBy(d); + if (inView && io.MouseWheel != 0) { + // Towards what's under the mouse: the target slides that way a little. + const float k = std::pow(0.85f, io.MouseWheel); + const Ray ray = mouseRay(io.MousePos); + if (auto p = hitPlane(ray, target_, -viewDir())) target_ += (*p - target_) * (1.0f - k); + dist_ = std::clamp(dist_ * k, 0.01f, 5000.0f); + } + const float aspect = io.DisplaySize.x / std::max(io.DisplaySize.y, 1.0f); + shift_ = glm::vec2((viewMin_.x + viewMax_.x) / std::max(io.DisplaySize.x, 1.0f) - 1.0f, (viewMin_.y + viewMax_.y) / std::max(io.DisplaySize.y, 1.0f) - 1.0f); + eye_ = target_ - viewDir() * dist_; + vp_ = viewProjection(eye_, yaw_, pitch_, fov_, aspect, shift_); + } + + // World point → screen (false behind the eye). + bool project(const glm::vec3& p, ImVec2& out) const { + const glm::vec4 c = vp_ * glm::vec4(p, 1.0f); + if (c.w < 1e-4f) return false; + const ImVec2 size = ImGui::GetIO().DisplaySize; + out = ImVec2((c.x / c.w * 0.5f + 0.5f) * size.x, (c.y / c.w * 0.5f + 0.5f) * size.y); + return true; + } + Ray mouseRay(ImVec2 m) const { + const ImVec2 size = ImGui::GetIO().DisplaySize; + const glm::vec2 ndc(m.x / size.x * 2.0f - 1.0f, m.y / size.y * 2.0f - 1.0f); + const glm::mat4 inv = glm::inverse(vp_); + glm::vec4 a = inv * glm::vec4(ndc, 0.0f, 1.0f), b = inv * glm::vec4(ndc, 1.0f, 1.0f); + a /= a.w, b /= b.w; + return {glm::vec3(a), glm::normalize(glm::vec3(b - a))}; + } + // How many metres a pixel is, at a point. + float pixelSize(const glm::vec3& at) const { + const float d = std::max(glm::dot(at - eye_, viewDir()), 0.001f); + return 2.0f * d * std::tan(glm::radians(fov_) * 0.5f) / std::max(ImGui::GetIO().DisplaySize.y, 1.0f); + } + + // ───────────────────────────── what's shown ───────────────────────────── + + // The mesh as seen: with its rounding, smoothing and mirror — but while its shape is being + // edited, only the mirror (the rest would hide the points you're moving). + const Mesh& shownMesh(const Object& o) { + const bool editingThis = mode_ == Mode::Edit && o.id == active_; + if (!o.hasFinish() || (editingThis && !o.mirror)) return o.mesh; + Shown& s = shown_[o.id]; + const uint64_t v = versions_[o.id]; + if (!(s.version == v && v && s.mirrorOnly == editingThis)) s.mesh = finishedMesh(o, editingThis), s.version = v, s.mirrorOnly = editingThis; + return s.mesh; + } + + // ───────────────────────────── picking ───────────────────────────── + + struct Hit { + uint32_t object = 0; + int face = -1; + float t = 1e30f; + }; + // base: against its own mesh (face numbers of that), not the rounded or smoothed one shown. + Hit pickObject(const Ray& ray, uint32_t only = 0, bool base = false) { + Hit best; + for (const Object& o : scene_.objects) { + if (!o.visible || (only && o.id != only)) continue; + const Mesh& mesh = base || (mode_ == Mode::Edit && o.id == active_) ? o.mesh : shownMesh(o); + const glm::mat4 m = objectMatrix(o); + std::vector w(mesh.verts.size()); + for (size_t i = 0; i < w.size(); ++i) w[i] = glm::vec3(m * glm::vec4(mesh.verts[i], 1.0f)); + for (size_t f = 0; f < mesh.faces.size(); ++f) + for (const auto& tri : triangulate(mesh, int(f))) + if (auto t = hitTriangle(ray, w[size_t(tri[0])], w[size_t(tri[1])], w[size_t(tri[2])]); t && *t < best.t) best = {o.id, int(f), *t}; + } + return best; + } + // Can the eye see this point of the object (nothing of it in the way)? + bool visible(const Object& o, const glm::vec3& world) { + if (xray_) return true; + const glm::vec3 d = world - eye_; + const float len = glm::length(d); + const Hit h = pickObject({eye_, d / len}, o.id); + return h.t > len - std::max(len * 0.002f, 0.0005f); + } + // Which of an object's points the eye can see, worked out again only when it or the view + // changes. (Very big meshes: all, rather than slow.) + struct Visibility { + uint64_t version = 0; + glm::vec3 eye{1e30f}; + std::vector point; + }; + std::map visibility_; + const std::vector& visiblePoints(const Object& o) { + Visibility& v = visibility_[o.id]; + const uint64_t version = versions_[o.id]; + if (v.version == version && glm::distance(v.eye, eye_) < 1e-6f && v.point.size() == o.mesh.verts.size()) return v.point; + v.version = version, v.eye = eye_; + v.point.assign(o.mesh.verts.size(), 1); + const glm::mat4 m = objectMatrix(o); + std::vector w(o.mesh.verts.size()); + for (size_t i = 0; i < w.size(); ++i) w[i] = glm::vec3(m * glm::vec4(o.mesh.verts[i], 1.0f)); + std::vector> tris; + for (size_t f = 0; f < o.mesh.faces.size(); ++f) + for (const auto& t : triangulate(o.mesh, int(f))) tris.push_back(t); + if (w.size() * tris.size() > 4'000'000) return v.point; + for (size_t i = 0; i < w.size(); ++i) { + const glm::vec3 d = w[i] - eye_; + const float len = glm::length(d); + const Ray ray{eye_, d / len}; + const float limit = len - std::max(len * 0.002f, 0.0005f); + for (const auto& t : tris) { + if (t[0] == int(i) || t[1] == int(i) || t[2] == int(i)) continue; + if (auto hit = hitTriangle(ray, w[size_t(t[0])], w[size_t(t[1])], w[size_t(t[2])]); hit && *hit < limit) { + v.point[i] = 0; + break; + } + } + } + return v.point; + } + bool pointSeen(const Object& o, int v) { return xray_ || visiblePoints(o)[size_t(v)]; } + + // The edge of the edited object nearest the mouse on screen (that can be seen), or (-1, -1). + std::pair nearestEdge(Object& o, ImVec2 mouse, float within) { + const glm::mat4 m = objectMatrix(o); + std::pair best{-1, -1}; + for (auto [a, b] : edges(o.mesh)) { + ImVec2 sa, sb; + if (!project(glm::vec3(m * glm::vec4(o.mesh.verts[size_t(a)], 1.0f)), sa) || !project(glm::vec3(m * glm::vec4(o.mesh.verts[size_t(b)], 1.0f)), sb)) continue; + const float d = distToSegment(mouse, sa, sb); + if (d < within && (pointSeen(o, a) || pointSeen(o, b))) within = d, best = {a, b}; + } + return best; + } + + void clickSelect(const ImGuiIO& io) { + const Ray ray = mouseRay(io.MousePos); + bool add = io.KeyShift, remove = io.KeyCtrl; + if (Object* o = editing()) { + // Shapes: Shift picks the whole loop (Shift+Ctrl on an edge: the ring across it), Ctrl + // adds or takes away one. + if (io.KeyShift) return loopSelect(io, io.KeyCtrl); + add = io.KeyCtrl, remove = false; + const glm::mat4 m = objectMatrix(*o); + auto toggle = [&](auto& set, auto key) { + if (remove) set.erase(key); + else if (add && set.count(key)) set.erase(key); + else set.insert(key); + }; + if (!add && !remove) clearElements(); + if (elem_ == Elem::Vertex) { + int best = -1; + float bestD = 14.0f; + for (size_t v = 0; v < o->mesh.verts.size(); ++v) { + ImVec2 s; + if (!project(glm::vec3(m * glm::vec4(o->mesh.verts[v], 1.0f)), s)) continue; + const float d = std::hypot(s.x - io.MousePos.x, s.y - io.MousePos.y); + if (d < bestD && pointSeen(*o, int(v))) bestD = d, best = int(v); + } + if (best >= 0) toggle(selVerts_, best); + } else if (elem_ == Elem::Edge) { + const auto e = nearestEdge(*o, io.MousePos, 10.0f); + if (e.first >= 0) toggle(selEdges_, e); + } else { + const Hit h = pickObject(ray, o->id); + if (h.face >= 0) toggle(selFaces_, h.face); + } + return; + } + const Hit h = pickObject(ray); + if (!add && !remove) selected_.clear(); + if (h.object) { + if (remove || (add && selected_.count(h.object))) selected_.erase(h.object); + else selected_.insert(h.object), active_ = h.object; + activeRef_ = 0; + } + } + + // A whole loop: of edges (points), or a ring of faces, through the edge under the mouse. + void loopSelect(const ImGuiIO& io, bool ring = false) { + Object* o = editing(); + if (!o) return; + if (!io.KeyShift) clearElements(); + if (elem_ == Elem::Face) { + const Hit h = pickObject(mouseRay(io.MousePos), o->id); + if (h.face < 0) return; + // The face's edge nearest the mouse: the ring runs across it. + const auto& fv = o->mesh.faces[size_t(h.face)]; + const glm::mat4 m = objectMatrix(*o); + std::pair best{-1, -1}; + float bestD = 1e30f; + for (size_t i = 0; i < fv.size(); ++i) { + ImVec2 sa, sb; + if (project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(fv[i])], 1.0f)), sa) && + project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(fv[(i + 1) % fv.size()])], 1.0f)), sb)) + if (const float d = distToSegment(io.MousePos, sa, sb); d < bestD) bestD = d, best = {fv[i], fv[(i + 1) % fv.size()]}; + } + if (best.first < 0) return; + const auto ring = faceRing(o->mesh, best); + selFaces_.insert(ring.begin(), ring.end()); + if (ring.empty()) selFaces_.insert(h.face); + return; + } + const auto e = nearestEdge(*o, io.MousePos, 14.0f); + if (e.first < 0) return; + const auto loop = ring ? edgeRing(o->mesh, e) : edgeLoop(o->mesh, e); + if (ring) say(std::format("A ring of {} edges", loop.size())); + if (elem_ == Elem::Edge) selEdges_.insert(loop.begin(), loop.end()); + else + for (auto [a, b] : loop) selVerts_.insert(a), selVerts_.insert(b); + } + + // Selects what's on screen inside a shape (a box, a lasso, the brush). + void regionSelect(const std::function& inside, bool add, bool remove, bool clearFirst) { + auto in = [&](const glm::vec3& w) { + ImVec2 s; + return project(w, s) && inside(s); + }; + if (Object* o = editing()) { + if (clearFirst) clearElements(); + const glm::mat4 m = objectMatrix(*o); + std::vector hit(o->mesh.verts.size()); + for (size_t v = 0; v < hit.size(); ++v) hit[v] = in(glm::vec3(m * glm::vec4(o->mesh.verts[v], 1.0f))) && pointSeen(*o, int(v)); + auto apply = [&](auto& set, auto key, bool yes) { + if (!yes) return; + if (remove) set.erase(key); + else set.insert(key); + }; + if (elem_ == Elem::Vertex) + for (size_t v = 0; v < hit.size(); ++v) apply(selVerts_, int(v), bool(hit[v])); + else if (elem_ == Elem::Edge) + for (auto e : edges(o->mesh)) { + const glm::vec3 mid(m * glm::vec4((o->mesh.verts[size_t(e.first)] + o->mesh.verts[size_t(e.second)]) * 0.5f, 1.0f)); + apply(selEdges_, e, (hit[size_t(e.first)] && hit[size_t(e.second)]) || (in(mid) && pointSeen(*o, e.first) && pointSeen(*o, e.second))); + } + else + for (size_t f = 0; f < o->mesh.faces.size(); ++f) { + const glm::vec3 c(m * glm::vec4(faceCenter(o->mesh, int(f)), 1.0f)); + if (!in(c)) continue; + const glm::vec3 n = glm::transpose(glm::inverse(glm::mat3(m))) * faceNormal(o->mesh, int(f)); + apply(selFaces_, int(f), xray_ || (glm::dot(n, eye_ - c) > 0 && visible(*o, c))); + } + return; + } + if (clearFirst) selected_.clear(); + (void)add; + for (const Object& o : scene_.objects) { + if (!o.visible) continue; + const glm::vec3 c(objectMatrix(o) * glm::vec4((boundsMin(o.mesh) + boundsMax(o.mesh)) * 0.5f, 1.0f)); + if (!in(c)) continue; + if (remove) selected_.erase(o.id); + else selected_.insert(o.id), active_ = o.id; + } + } + + // ───────────────────────────── the gizmo ───────────────────────────── + + bool hasSelection() { + if (mode_ == Mode::Edit) return !selectedPoints().empty(); + return std::any_of(selected_.begin(), selected_.end(), [&](uint32_t id) { const Object* o = find(id); return o && !o->locked; }); + } + // Every point of the selection, in the world. + std::vector selectionPoints() { + std::vector out; + if (Object* o = editing()) { + const glm::mat4 m = objectMatrix(*o); + for (int v : selectedPoints()) out.push_back(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f))); + return out; + } + for (const Object& o : scene_.objects) { + if (!selected_.count(o.id) || o.locked) continue; + const glm::mat4 m = objectMatrix(o); + for (const glm::vec3& v : shownMesh(o).verts) out.push_back(glm::vec3(m * glm::vec4(v, 1.0f))); + } + return out; + } + glm::vec3 pivotOfSelection() { + glm::vec3 c(0.0f); + int n = 0; + if (Object* o = editing()) { + const glm::mat4 m = objectMatrix(*o); + for (int v : selectedPoints()) c += glm::vec3(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)), ++n; + } else { + for (const Object& o : scene_.objects) + if (selected_.count(o.id)) c += o.position, ++n; + } + return n ? c / float(n) : glm::vec3(0.0f); + } + // The selection's box, in a gizmo's axes. + std::pair selectionBox(const Axes& a) { + glm::vec3 lo(1e30f), hi(-1e30f); + for (const glm::vec3& p : selectionPoints()) { + const glm::vec3 c = inFrame(a, p); + lo = glm::min(lo, c), hi = glm::max(hi, c); + } + if (lo.x > hi.x) lo = hi = inFrame(a, pivotOfSelection()); + return {lo, hi}; + } + glm::vec3 selectionMiddle(const Axes& a) { + const auto [lo, hi] = selectionBox(a); + return fromFrame(a, (lo + hi) * 0.5f); + } + Orient& orient() { return mode_ == Mode::Edit ? orientEdit_ : orientObject_; } + // The gizmo's axes: the object's own (turning with it), the world's, or (shape editing) the + // way the selected faces face — so "up" on a side face is out of that face. + Axes gizmoAxes() { + Orient o = orient(); + if (mode_ == Mode::Object && tool_ == Tool::Scale) o = Orient::Local; // (sizes are along an object's own axes) + const Object* a = find(active_); + if (!a && !selected_.empty()) a = find(*selected_.begin()); + const glm::quat q = a && o != Orient::World ? a->rotation : glm::quat(1, 0, 0, 0); + Axes local{q * glm::vec3(1, 0, 0), q * glm::vec3(0, 1, 0), q * glm::vec3(0, 0, 1)}; + if (o != Orient::Normal || mode_ != Mode::Edit) return local; + Object* e = editing(); + if (!e) return local; + const glm::mat4 m = objectMatrix(*e); + const glm::mat3 nm = glm::transpose(glm::inverse(glm::mat3(m))); + const std::set pts = selectedPoints(); + glm::vec3 n(0.0f), along(0.0f); + for (size_t f = 0; f < e->mesh.faces.size(); ++f) { + const auto& fv = e->mesh.faces[f]; + const bool whole = elem_ == Elem::Face ? selFaces_.count(int(f)) > 0 : std::any_of(fv.begin(), fv.end(), [&](int v) { return pts.count(v); }); + if (!whole) continue; + n += nm * faceNormal(e->mesh, int(f)) * std::max(faceArea(e->mesh, int(f)), 1e-6f); + if (glm::length(along) < 1e-9f) // the first face's longest edge: "across" it + for (size_t i = 0; i < fv.size(); ++i) { + const glm::vec3 d = glm::mat3(m) * (e->mesh.verts[size_t(fv[(i + 1) % fv.size()])] - e->mesh.verts[size_t(fv[i])]); + if (glm::length(d) > glm::length(along)) along = d; + } + } + if (elem_ == Elem::Edge && !selEdges_.empty()) { + const auto [a0, b0] = *selEdges_.begin(); + along = glm::mat3(m) * (e->mesh.verts[size_t(b0)] - e->mesh.verts[size_t(a0)]); + } + if (glm::length(n) < 1e-9f) return local; + const glm::vec3 z = glm::normalize(n); + glm::vec3 x = along - z * glm::dot(along, z); + if (glm::length(x) < 1e-6f) x = glm::cross(std::abs(z.z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0), z); + x = glm::normalize(x); + return {x, glm::cross(z, x), z}; + } + // The extrude / inset handle: the selected faces' middle, their normal. + bool faceHandle(glm::vec3& at, glm::vec3& normal) { + Object* o = editing(); + if (!o || elem_ != Elem::Face || selFaces_.empty()) return false; + const glm::mat4 m = objectMatrix(*o); + glm::vec3 c(0.0f), n(0.0f); + for (int f : selFaces_) { + if (f >= int(o->mesh.faces.size())) return false; + c += glm::vec3(m * glm::vec4(faceCenter(o->mesh, f), 1.0f)); + n += glm::normalize(glm::transpose(glm::inverse(glm::mat3(m))) * faceNormal(o->mesh, f)); + } + at = c / float(selFaces_.size()); + normal = glm::length(n) > 1e-6f ? glm::normalize(n) : glm::vec3(0, 0, 1); + return true; + } + // The round-edges handle: off to the right of the selection's middle. + bool bevelHandle(ImVec2& centre, ImVec2& handle) { + if (!editing() || edgesToRound().empty()) return false; + if (!project(drag_.active ? drag_.pivot : pivotOfSelection(), centre)) return false; + handle = ImVec2(centre.x + 80.0f + (drag_.active ? ImGui::GetIO().MousePos.x - drag_.startMouse.x : 0.0f), centre.y); + return true; + } + // The scale box's handles: 0-2 the far side along each axis, 4-6 the near side, 3 the middle. + // (Pushed out a little where the box is flat, so they don't sit on top of each other.) + glm::vec3 scaleHandle(const Axes& a, glm::vec3 lo, glm::vec3 hi, int h) { + const glm::vec3 c = (lo + hi) * 0.5f; + if (h == 3) return fromFrame(a, c); + const int k = h % 4 == 3 ? 0 : (h < 3 ? h : h - 4); + glm::vec3 q = c; + const float minHalf = pixelSize(fromFrame(a, c)) * 22.0f; + const float half = std::max((hi[k] - lo[k]) * 0.5f, minHalf); + q[k] = c[k] + (h < 3 ? half : -half); + return fromFrame(a, q); + } + + // Which handle is under the mouse (-1 none). + int hoverHandle(ImVec2 mouse) { + if (drag_.active) return drag_.handle; + if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { + glm::vec3 at, n; + if (!faceHandle(at, n)) return -1; + ImVec2 a, b; + if (!project(at, a) || !project(at + n * pixelSize(at) * 90.0f, b)) return -1; + return distToSegment(mouse, a, b) < 10.0f ? 0 : -1; + } + if (tool_ == Tool::Bevel) { + ImVec2 c, h; + return bevelHandle(c, h) && std::hypot(mouse.x - h.x, mouse.y - h.y) < 12.0f ? 0 : -1; + } + if (tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) return -1; + if (!hasSelection()) return -1; + const Axes axes = gizmoAxes(); + if (tool_ == Tool::Scale) { + const auto [lo, hi] = selectionBox(axes); + for (int h : {3, 0, 1, 2, 4, 5, 6}) { + ImVec2 s; + if (project(scaleHandle(axes, lo, hi, h), s) && std::abs(mouse.x - s.x) < 9 && std::abs(mouse.y - s.y) < 9) return h; + } + return -1; + } + const glm::vec3 p = tool_ == Tool::Rotate ? rotatePivot(axes) : selectionMiddle(axes); + ImVec2 c; + if (!project(p, c)) return -1; + const float len = pixelSize(p) * 90.0f; + if (tool_ == Tool::Rotate) { + int best = -1; + float bestD = 9.0f; + for (int k = 0; k < 3; ++k) { + const glm::vec3 u = glm::normalize(glm::cross(axes[size_t(k)], std::abs(axes[size_t(k)].z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0))); + const glm::vec3 v = glm::cross(axes[size_t(k)], u); + ImVec2 prev; + bool havePrev = false; + for (int i = 0; i <= 48; ++i) { + const float a = 2 * kPi * float(i) / 48.0f; + ImVec2 s; + const glm::vec3 w = p + (u * std::cos(a) + v * std::sin(a)) * len; + if (!project(w, s)) { + havePrev = false; + continue; + } + if (havePrev && glm::dot(w - p, eye_ - p) > -0.2f * len * glm::length(eye_ - p)) // (the front half only) + if (const float d = distToSegment(mouse, prev, s); d < bestD) bestD = d, best = k; + prev = s, havePrev = true; + } + } + const float r = std::hypot(mouse.x - c.x, mouse.y - c.y); + if (best < 0 && r > 95.0f && r < 110.0f) best = 3; // the outer ring: about the view + return best; + } + if (std::abs(mouse.x - c.x) < 9 && std::abs(mouse.y - c.y) < 9) return 3; + for (int k = 0; k < 3; ++k) { + ImVec2 e; + if (project(p + axes[size_t(k)] * len, e) && distToSegment(mouse, c, e) < 9.0f) return k; + } + return -1; + } + + glm::vec3 originPoint() { + if (Object* o = editing()) return o->position; + glm::vec3 c(0.0f); + int n = 0; + for (const Object& o : scene_.objects) + if (selected_.count(o.id)) c += o.position, ++n; + return n ? c / float(n) : c; + } + glm::vec3 rotatePivot(const Axes& a) { return rotatePivot_ == Pivot::Origin ? originPoint() : selectionMiddle(a); } + + void startDrag(const ImGuiIO& io, int handle) { + drag_ = {}; + drag_.active = true, drag_.handle = handle, drag_.tool = tool_; + drag_.startMouse = io.MousePos; + drag_.before = scene_; + pushUndo(); + const Ray ray = mouseRay(io.MousePos); + if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { + glm::vec3 at, n; + faceHandle(at, n); + drag_.pivot = at, drag_.startT = closestOnAxis(ray, at, n); + drag_.startHit = n; + return; + } + if (tool_ == Tool::Bevel) { + drag_.pivot = pivotOfSelection(); + drag_.bevelEdges = edgesToRound(); + return; + } + drag_.axes = gizmoAxes(); + const Axes& a = drag_.axes; + if (tool_ == Tool::Scale) { + const auto [lo, hi] = selectionBox(a); + const glm::vec3 c = (lo + hi) * 0.5f; + drag_.handlePoint = scaleHandle(a, lo, hi, handle); + const int k = handle < 3 ? handle : handle - 4; + glm::vec3 q = c; + switch (scalePivot_) { + case Pivot::Opposite: + if (handle == 3) q[2] = lo[2]; + else q[k] = handle < 3 ? lo[k] : hi[k]; + drag_.anchor = fromFrame(a, q); + break; + case Pivot::Bottom: q[2] = lo[2], drag_.anchor = fromFrame(a, q); break; + case Pivot::Origin: drag_.anchor = originPoint(); break; + default: drag_.anchor = fromFrame(a, c); break; + } + drag_.pivot = fromFrame(a, c); + if (handle != 3) drag_.startDist = glm::dot(drag_.handlePoint - drag_.anchor, a[size_t(k)]), drag_.startMid = glm::dot(drag_.handlePoint - drag_.pivot, a[size_t(k)]); + return; + } + drag_.pivot = tool_ == Tool::Rotate ? rotatePivot(a) : selectionMiddle(a); + if (handle < 3) drag_.startT = closestOnAxis(ray, drag_.pivot, a[size_t(handle)]); + if (auto hit = hitPlane(ray, drag_.pivot, -viewDir())) drag_.startHit = *hit; + } + void cancelDrag() { + if (!drag_.active) return; + scene_ = drag_.before; + undo_.pop_back(); + touchAll(); + drag_.active = false; + } + + void updateDrag(const ImGuiIO& io) { + const Ray ray = mouseRay(io.MousePos); + const bool snapping = snap_ != io.KeyCtrl; // Ctrl flips snapping while held + scene_ = drag_.before; // every frame from where it started: no drift + const Axes& axes = drag_.axes; + const glm::vec3 p = drag_.pivot; + glm::mat4 T(1.0f); // what happens to the selection, in the world + if (drag_.tool == Tool::Extrude || drag_.tool == Tool::Inset) { + Object* o = editing(); + if (!o) return; + const glm::vec3 n = drag_.startHit; + float d = closestOnAxis(ray, p, n) - drag_.startT; + if (drag_.tool == Tool::Extrude) { + if (snapping) d = snapTo(d, snapMove_); + // (the pull in the object's own size, so it follows the mouse whatever its scale) + const float scale = std::cbrt(std::abs(glm::determinant(glm::mat3(objectMatrix(*o))))); + extrudeFaces(o->mesh, selFaces_, d / std::max(scale, 1e-6f)); + extrudeAmount_ = d; + message_ = std::format("Extrude {:.3f} m", d), messageAt_ = now(); + } else { + float t = std::clamp(-d / (pixelSize(p) * 120.0f), 0.0f, 0.99f); + if (snapping) t = snapTo(t, 0.05f); + insetFaces(o->mesh, selFaces_, t); + insetAmount_ = t; + message_ = std::format("Inset {:.0f}%", t * 100), messageAt_ = now(); + } + changedShape(*o, true); + return; + } + if (drag_.tool == Tool::Bevel) { + Object* o = editing(); + if (!o) return; + float w = std::max(0.0f, io.MousePos.x - drag_.startMouse.x) * pixelSize(p) * 0.5f; + if (snapping) w = snapTo(w, 0.005f); + if (w > 0) bevelEdges(o->mesh, drag_.bevelEdges, w, bevelSteps_); + bevelWidth_ = w; + handMade(*o); + touched(o->id); + message_ = std::format("Round edges {:.3f} m, {} step{}", w, bevelSteps_, bevelSteps_ == 1 ? "" : "s"), messageAt_ = now(); + return; + } + glm::vec3 objectScale(1.0f); + float even = 1.0f; + if (drag_.tool == Tool::Move) { + glm::vec3 delta(0.0f); + if (drag_.handle < 3) { + float t = closestOnAxis(ray, p, axes[size_t(drag_.handle)]) - drag_.startT; + if (snapping) t = snapTo(t, snapMove_); + delta = axes[size_t(drag_.handle)] * t; + } else if (auto hit = hitPlane(ray, p, -viewDir())) { + delta = *hit - drag_.startHit; + if (snapping) delta = glm::vec3(snapTo(delta.x, snapMove_), snapTo(delta.y, snapMove_), snapTo(delta.z, snapMove_)); + } + T = glm::translate(glm::mat4(1.0f), delta); + message_ = std::format("Move {:.3f}, {:.3f}, {:.3f} m", delta.x, delta.y, delta.z), messageAt_ = now(); + } else if (drag_.tool == Tool::Rotate) { + ImVec2 c; + if (!project(p, c)) return; + const float a0 = std::atan2(drag_.startMouse.y - c.y, drag_.startMouse.x - c.x), a1 = std::atan2(io.MousePos.y - c.y, io.MousePos.x - c.x); + float deg = glm::degrees(std::remainder(a1 - a0, 2 * kPi)); + const glm::vec3 axis = drag_.handle < 3 ? axes[size_t(drag_.handle)] : -viewDir(); + if (glm::dot(axis, viewDir()) < 0) deg = -deg; // (turning the way the mouse goes round, as seen) + if (snapping) deg = snapTo(deg, snapAngle_); + T = glm::translate(glm::mat4(1.0f), p) * glm::rotate(glm::mat4(1.0f), glm::radians(deg), axis) * glm::translate(glm::mat4(1.0f), -p); + message_ = std::format("Rotate {:.1f} degrees", deg), messageAt_ = now(); + } else if (drag_.tool == Tool::Scale) { + glm::vec3 s(1.0f); + // Shift (or Alt) held: from both sides, the middle staying put. + const bool both = (io.KeyShift || io.KeyAlt) && drag_.handle != 3; + if (drag_.handle != 3) { + const int k = drag_.handle < 3 ? drag_.handle : drag_.handle - 4; + const float from = both ? drag_.startMid : drag_.startDist; + float dist = from + closestOnAxis(ray, drag_.handlePoint, axes[size_t(k)]); + if (snapping) dist = both ? snapTo(dist * 2, snapMove_) / 2 : snapTo(dist, snapMove_); + const float f = std::abs(from) > 1e-6f ? dist / from : 1.0f; + s[k] = f; + message_ = std::format("Scale {:.2f} ({:.3f} m){}", f, std::abs(dist) * (both ? 2 : 1), both ? " from both sides" : " (Shift: from both sides)"), + messageAt_ = now(); + } else { + even = std::max(0.01f, 1.0f + ((io.MousePos.x - drag_.startMouse.x) - (io.MousePos.y - drag_.startMouse.y)) / 200.0f); + if (snapping) even = std::max(snapScale_, snapTo(even, snapScale_)); + s = glm::vec3(even); + message_ = std::format("Scale {:.2f} (drag right or up to grow)", even), messageAt_ = now(); + } + const glm::mat3 basis(axes[0], axes[1], axes[2]); + const glm::vec3 a = both ? drag_.pivot : drag_.anchor; + T = glm::translate(glm::mat4(1.0f), a) * glm::mat4(basis) * glm::scale(glm::mat4(1.0f), s) * glm::mat4(glm::transpose(basis)) * glm::translate(glm::mat4(1.0f), -a); + objectScale = s; + } + if (Object* o = editing()) { + const glm::mat4 m = objectMatrix(*o), local = glm::inverse(m) * T * m; + for (int v : selectedPoints()) o->mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)); + handMade(*o); + touched(o->id); + return; + } + for (Object& o : scene_.objects) { + if (!selected_.count(o.id) || o.locked) continue; + o.position = glm::vec3(T * glm::vec4(o.position, 1.0f)); + if (drag_.tool == Tool::Rotate) o.rotation = glm::normalize(glm::quat_cast(glm::mat3(T)) * o.rotation); + if (drag_.tool == Tool::Scale) { + // On its own axes: the gizmo's where they're the same, else how much each of its + // axes lies along each of the gizmo's. + for (int j = 0; j < 3; ++j) { + const glm::vec3 own = o.rotation * glm::vec3(j == 0, j == 1, j == 2); + float f = 1.0f; + for (int k = 0; k < 3; ++k) f += (objectScale[k] - 1.0f) * glm::dot(own, axes[size_t(k)]) * glm::dot(own, axes[size_t(k)]); + o.scale[j] *= f; + } + } + touched(o.id); + } + } + + // ───────────────────────────── drawing a shape ───────────────────────────── + + // The plane shapes are drawn on: the one the view looks straight at, or the ground. + void workPlane(glm::vec3& point, glm::vec3& normal) const { + point = glm::vec3(0.0f); + normal = view_ == View::Front || view_ == View::Back ? glm::vec3(1, 0, 0) : view_ == View::Left || view_ == View::Right ? glm::vec3(0, 1, 0) : glm::vec3(0, 0, 1); + } + // The plane drawn on: the face under the first point if it was on an object, else the view's. + void drawPlane(glm::vec3& p, glm::vec3& n) { + if (drawOnto_ && find(drawOnto_)) p = drawOnP_, n = drawOnN_; + else workPlane(p, n); + } + std::optional drawPoint(const ImGuiIO& io) { + const Ray ray = mouseRay(io.MousePos); + if (drawPts_.empty()) { // (the first point: on an object, its face is where it's drawn) + drawOnto_ = 0; + const Hit h = pickObject(ray, 0, true); + if (const Object* o = h.object ? find(h.object) : nullptr) { + const glm::mat4 m = objectMatrix(*o); + drawOnto_ = o->id; + drawOnP_ = ray.origin + ray.dir * h.t; + drawOnN_ = glm::normalize(glm::transpose(glm::inverse(glm::mat3(m))) * faceNormal(o->mesh, h.face)); + } + } + glm::vec3 p, n; + drawPlane(p, n); + auto hit = hitPlane(ray, p, n); + if (!hit) return std::nullopt; + if (snap_ != io.KeyCtrl) { // (on the grid, then back onto the plane) + const float step = gridStep() / 2.0f; + *hit = glm::vec3(snapTo(hit->x, step), snapTo(hit->y, step), snapTo(hit->z, step)); + *hit -= n * glm::dot(*hit - p, n); + } + return hit; + } + void finishShape() { + if (drawPts_.size() < 3) { + drawPts_.clear(); + return say("A shape needs 3 points or more"); + } + glm::vec3 p, n; + drawPlane(p, n); + const uint32_t onto = drawOnto_ && find(drawOnto_) ? drawOnto_ : 0; + drawOnto_ = 0; + glm::vec3 c(0.0f); + for (const glm::vec3& q : drawPts_) c += q; + c /= float(drawPts_.size()); + std::vector local; + for (const glm::vec3& q : drawPts_) local.push_back(q - c); + // On an object it stands on the face (a hair into it, so they join), or goes down into it. + const float t = drawThickness_, e = 0.0001f; + float thick = t; + if (onto && drawOnMode_ == 2) thick = t + 2 * e, c += n * (e - thick / 2); + else if (onto) thick = t + e, c += n * (t - thick / 2); + Mesh m = extrudeOutline(local, n, thick); + drawPts_.clear(); + if (m.faces.empty()) return say("That shape doesn't work (points on top of each other?)"); + pushUndo(); + if (onto && drawOnMode_ > 0) { + Object* o = find(onto); + if (o->locked) return say("That object is locked"); + if (o->hasFinish()) o->mesh = finishedMesh(*o), o->mirror = 0, o->round = 0, o->smoothLevels = 0; + Mesh shape = m; + transform(shape, glm::inverse(objectMatrix(*o)) * glm::translate(glm::mat4(1.0f), c)); + Mesh result = o->mesh; + if (!combine(result, shape, drawOnMode_ == 2 ? Combine::Subtract : Combine::Add)) { + undo(), redo_.clear(); + return say("That would leave nothing"); + } + o = find(onto); + o->mesh = std::move(result); + changedShape(*o); + selected_ = {o->id}, active_ = o->id; + return say(std::format("{} {:.0f} mm {} {}", drawOnMode_ == 2 ? "Cut" : "Added", t * 1000, drawOnMode_ == 2 ? "deep into" : "high onto", o->name)); + } + Object& o = addObject("Shape", std::move(m)); + o.position = c; + selected_ = {o.id}, active_ = o.id; + say(std::format("Shape made, {:.0f} mm thick{}. Tab edits its shape; Thickness is in Tool options.", t * 1000, onto ? ", standing on " + find(onto)->name : "")); + } + + // ───────────────────────────── the viewport's mouse ───────────────────────────── + + void viewport(ImGuiIO& io) { + cutHover_ = {-1, -1}; + if (tab_ == Tab::Paint) return paintViewport(io); + if (tab_ == Tab::Layout) return layoutViewport(io); + if (tab_ == Tab::Print && printTool_ != PrintTool::None) return printViewport(io); + if (knifeDown_ && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + knifeDown_ = false; + const ImVec2 end = knifeEnd(io); + if (knifePts_.empty() && std::hypot(end.x - knifePress_.x, end.y - knifePress_.y) > 8) { + if (Object* o = editing()) knifeApply(o->id, {knifePress_, end}); + } else + knifePts_.push_back(end); + return; + } + if (hole_.active) { + Object* o = find(hole_.object); + if (!o) hole_.active = false; + else if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + if (auto p = onFacePlane(*o, hole_.face, io.MousePos)) hole_.radius = glm::distance(*p, hole_.centre); + message_ = std::format("{:.1f} mm across", holeAcrossFor(hole_.radius) * objectScale(*o) * 1000), messageAt_ = now(); + } else { + hole_.active = false; + const bool dragged = std::hypot(io.MousePos.x - hole_.press.x, io.MousePos.y - hole_.press.y) > 4; + applyHole(hole_.object, hole_.face, hole_.centre, dragged ? holeAcrossFor(hole_.radius) : holeSize_ / objectScale(*o)); + } + return; + } + if (drag_.active) { + if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) updateDrag(io); + else { + drag_.active = false; + if (drag_.tool == Tool::Bevel) clearElements(); // (renumbered) + } + return; + } + if (boxing_) { + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + boxing_ = false; + if (std::hypot(io.MousePos.x - boxStart_.x, io.MousePos.y - boxStart_.y) > 4) { + const ImVec2 a(std::min(boxStart_.x, io.MousePos.x), std::min(boxStart_.y, io.MousePos.y)), b(std::max(boxStart_.x, io.MousePos.x), std::max(boxStart_.y, io.MousePos.y)); + regionSelect([&](ImVec2 s) { return s.x >= a.x && s.x <= b.x && s.y >= a.y && s.y <= b.y; }, io.KeyShift, io.KeyCtrl, !io.KeyShift && !io.KeyCtrl); + } else + clickSelect(io); + } + return; + } + if (lassoing_) { + if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + if (std::hypot(io.MousePos.x - lasso_.back().x, io.MousePos.y - lasso_.back().y) > 3) lasso_.push_back(io.MousePos); + } else { + lassoing_ = false; + if (lasso_.size() > 4) regionSelect([&](ImVec2 s) { return insidePolygon(s, lasso_); }, io.KeyShift, io.KeyCtrl, !io.KeyShift && !io.KeyCtrl); + else clickSelect(io); + } + return; + } + if (painting_) { + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) painting_ = false; + else paint(io); + return; + } + if (!mouseInView(io)) return; + if (tool_ == Tool::Draw) { + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) + if (auto p = drawPoint(io)) { + ImVec2 s0, s1; + if (drawPts_.size() >= 3 && project(drawPts_[0], s0) && project(*p, s1) && std::hypot(s0.x - s1.x, s0.y - s1.y) < 10) finishShape(); + else drawPts_.push_back(*p); + } + return; + } + if (tool_ == Tool::Knife) { + if (!editing()) return; + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && knifePts_.size() >= 2) { + std::vector pts = knifePts_; + knifePts_.clear(); + return knifeApply(active_, pts); + } + knifeDown_ = true, knifePress_ = io.MousePos; + } + return; + } + if (tool_ == Tool::Hole) { + if (!ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return; + const Ray ray = mouseRay(io.MousePos); + const Hit h = pickObject(ray, mode_ == Mode::Edit ? active_ : 0, true); + if (!h.object) return; + Object* o = find(h.object); + if (o->locked) return say("That object is locked"); + if (mode_ == Mode::Object) selected_ = {h.object}, active_ = h.object; + hole_ = {true, h.object, h.face, glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(ray.origin + ray.dir * h.t, 1.0f)), 0.0f, io.MousePos}; + return; + } + if (tool_ == Tool::LoopCut) { + if (Object* o = editing()) { + cutHover_ = nearestEdge(*o, io.MousePos, 30.0f); + if (cutHover_.first >= 0 && ringQuads(o->mesh, cutHover_).empty()) cutHover_ = {-1, -1}; + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && cutHover_.first >= 0) cutAt(*o, cutHover_); + } + return; + } + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + if (const int h = hoverHandle(io.MousePos); h >= 0) return startDrag(io, h); + if (mode_ == Mode::Edit && (io.KeyAlt || ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left))) return loopSelect(io); + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && mode_ == Mode::Object) { // double click: edit its shape + const Hit hit = pickObject(mouseRay(io.MousePos)); + if (hit.object) { + selected_ = {hit.object}, active_ = hit.object; + enterEdit(); + return; + } + } + if (selShape_ == SelShape::Brush) { + painting_ = true; + if (!io.KeyShift && !io.KeyCtrl) selectNone(); + paint(io); + } else if (selShape_ == SelShape::Lasso) lassoing_ = true, lasso_ = {io.MousePos}; + else boxing_ = true, boxStart_ = io.MousePos; + } + } + // Painting: on the model under the mouse. + void paintViewport(ImGuiIO& io) { + if (paint_.waiting) { + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) paint_.waiting = false; + else if (pickObject(mouseRay(io.MousePos), 0, true).object) paint_.waiting = false, paintAt(io.MousePos, true); + return; + } + if (paint_.active) { + if (paint_.in2D) return; + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) return endStroke(); + return paintAt(io.MousePos, false); + } + if (PictureLayer* l = editLayer()) { // (a picture being moved: on the model if it's a sticker) + if (l->sticker) stickerMouse(io); + return; + } + if (!mouseInView(io) || !ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return; + const Ray ray = mouseRay(io.MousePos); + const Hit h = pickObject(ray, 0, true); + if (!h.object) { + paint_.waiting = paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser; + return; + } + const bool picking = paintTool_ == PaintTool::Picker || io.KeyAlt; + if (paintTool_ == PaintTool::Picture) { + selected_ = {h.object}, active_ = h.object; + return; + } + if (picking) { + const Object* o = find(h.object); + if (o->picture) { + if (auto uv = uvUnder(*o, ray)) pickColor(*o, *uv); + } else + paintColor_ = glm::vec4(o->color, paintColor_.a); + return; + } + if (paintTool_ == PaintTool::Fill) { + selected_ = {h.object}, active_ = h.object; + Object* o = readyToPaint(h.object); + if (!o) return; + if (auto uv = uvUnder(*o, ray)) fillAt(*o, *uv); + return; + } + paintAt(io.MousePos, true); + } + + // The brush: whatever passes under it is selected (or, with Ctrl, let go). + void paint(const ImGuiIO& io) { + if (mode_ == Mode::Object) { + const Hit h = pickObject(mouseRay(io.MousePos)); + if (h.object) io.KeyCtrl ? (void)selected_.erase(h.object) : (void)(selected_.insert(h.object), active_ = h.object); + return; + } + const ImVec2 m = io.MousePos; + const float r = brushRadius_; + regionSelect([&](ImVec2 s) { return std::hypot(s.x - m.x, s.y - m.y) < r; }, true, io.KeyCtrl, false); + } + + // ───────────────────────────── keys ───────────────────────────── + + void shortcuts(ImGuiIO& io) { + if (io.WantTextInput) return; + const bool ctrl = io.KeyCtrl, shift = io.KeyShift, alt = io.KeyAlt; + auto key = [](ImGuiKey k) { return ImGui::IsKeyPressed(k, false); }; + if (ctrl && key(ImGuiKey_1)) setTab(Tab::Model); + if (ctrl && key(ImGuiKey_2)) setTab(Tab::Paint); + if (ctrl && key(ImGuiKey_3)) setTab(Tab::Layout); + if (tab_ == Tab::Paint && !ctrl && !alt) { + if (key(ImGuiKey_B)) paintTool_ = PaintTool::Brush; + if (key(ImGuiKey_E)) paintTool_ = PaintTool::Eraser; + if (key(ImGuiKey_G)) paintTool_ = PaintTool::Fill; + if (key(ImGuiKey_I)) paintTool_ = PaintTool::Picker; + if (ImGui::IsKeyPressed(ImGuiKey_LeftBracket)) brushPx_ = std::max(1.0f, brushPx_ / 1.2f); + if (ImGui::IsKeyPressed(ImGuiKey_RightBracket)) brushPx_ = std::min(500.0f, brushPx_ * 1.2f); + if (key(ImGuiKey_Enter) || key(ImGuiKey_KeypadEnter)) lockLayer(); + if (key(ImGuiKey_Delete) && editLayer()) deleteLayer(*find(ledit_.object), ledit_.index); + if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; + if (key(ImGuiKey_F12)) dialog(5); + if (key(ImGuiKey_F)) frameSelected(); + if (key(ImGuiKey_Home)) frameAll(); + if (key(ImGuiKey_Z)) shading_ = shading_ == Shading::Solid ? Shading::Rendered : Shading::Solid; + return; + } + if (tab_ == Tab::Paint && ctrl && key(ImGuiKey_V)) return pasteFromClipboard(); + if (tab_ == Tab::Paint && ctrl && key(ImGuiKey_Z) && editLayer() && ledit_.fresh) return cancelFloating(); + if (ctrl && key(ImGuiKey_4)) setTab(Tab::Render); + if (ctrl && key(ImGuiKey_5)) setTab(Tab::Print); + if (tab_ == Tab::Layout && !ctrl && !alt) { + if (key(ImGuiKey_Q)) layTool_ = LayTool::Select; + if (key(ImGuiKey_W)) layTool_ = LayTool::Move; + if (key(ImGuiKey_E)) layTool_ = LayTool::Rotate; + if (key(ImGuiKey_R)) layTool_ = LayTool::Scale; + if (key(ImGuiKey_Tab)) lay_.corners = !lay_.corners, lay_.frames.clear(), lay_.points.clear(); + if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; + if (key(ImGuiKey_F)) frameSelected(); + if (key(ImGuiKey_Home)) frameAll(); + return; + } + if (tab_ == Tab::Layout && ctrl && key(ImGuiKey_A)) return layoutSelectAll(); + if (tab_ == Tab::Print && !ctrl && !alt) { + if (key(ImGuiKey_M)) printTool_ = printTool_ == PrintTool::Measure ? PrintTool::None : PrintTool::Measure, measure_.clear(); + if (key(ImGuiKey_L)) printTool_ = printTool_ == PrintTool::LayFlat ? PrintTool::None : PrintTool::LayFlat; + if (key(ImGuiKey_B)) putOnBed(); + if (key(ImGuiKey_Q) || key(ImGuiKey_W) || key(ImGuiKey_E) || key(ImGuiKey_R)) printTool_ = PrintTool::None; + } + if ((tab_ == Tab::Render || tab_ == Tab::Print) && !ctrl && !alt) { + if (key(ImGuiKey_Q)) setTool(Tool::Select); + if (key(ImGuiKey_W)) setTool(Tool::Move); + if (key(ImGuiKey_E)) setTool(Tool::Rotate); + if (key(ImGuiKey_R)) setTool(Tool::Scale); + if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; + if (key(ImGuiKey_F12)) dialog(5); + if (key(ImGuiKey_F)) frameSelected(); + if (key(ImGuiKey_Home)) frameAll(); + if (key(ImGuiKey_Delete)) deleteSelected(); + return; + } + if (ctrl) { + if (key(ImGuiKey_Z)) shift ? redo() : undo(); + if (key(ImGuiKey_Y)) redo(); + if (key(ImGuiKey_S)) shift || path_.empty() ? dialog(2) : void(saveFile(path_)); + if (key(ImGuiKey_O)) whenSaved(3); + if (key(ImGuiKey_N)) whenSaved(2); + if (key(ImGuiKey_E)) dialog(3); + if (key(ImGuiKey_Q)) whenSaved(1); + if (key(ImGuiKey_D)) duplicateSelected(); + if (key(ImGuiKey_A)) selectAll(); + if (key(ImGuiKey_I)) invertSelection(); + if (key(ImGuiKey_J)) joinSelected(); + if (key(ImGuiKey_G)) groupSelected(); + if (shift && (key(ImGuiKey_Equal) || key(ImGuiKey_KeypadAdd))) return combineSelected(Combine::Add); + if (shift && (key(ImGuiKey_Minus) || key(ImGuiKey_KeypadSubtract))) return combineSelected(Combine::Subtract); + if (key(ImGuiKey_Equal) || key(ImGuiKey_KeypadAdd)) growSelection(); + if (key(ImGuiKey_Minus) || key(ImGuiKey_KeypadSubtract)) shrinkSelection(); + return; + } + if (alt) { + if (key(ImGuiKey_A)) selectNone(); + if (key(ImGuiKey_Z)) xray_ = !xray_; + if (key(ImGuiKey_H)) + for (Object& o : scene_.objects) o.visible = true; + return; + } + if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; + if (key(ImGuiKey_F12)) dialog(5); + if (key(ImGuiKey_Q)) setTool(Tool::Select); + if (key(ImGuiKey_W)) setTool(Tool::Move); + if (key(ImGuiKey_E)) setTool(Tool::Rotate); + if (key(ImGuiKey_R)) setTool(Tool::Scale); + if (key(ImGuiKey_D)) setTool(Tool::Draw); + if (key(ImGuiKey_X)) setTool(Tool::Extrude); + if (key(ImGuiKey_I)) setTool(Tool::Inset); + if (key(ImGuiKey_B)) setTool(Tool::Bevel); + if (key(ImGuiKey_C)) setTool(Tool::LoopCut); + if (key(ImGuiKey_K)) setTool(Tool::Knife); + if (key(ImGuiKey_O)) setTool(Tool::Hole); + if (key(ImGuiKey_Tab)) mode_ == Mode::Edit ? leaveEdit() : enterEdit(); + if (key(ImGuiKey_Delete) || key(ImGuiKey_Backspace)) deleteSelected(); + if (key(ImGuiKey_F)) frameSelected(); + if (key(ImGuiKey_Home)) frameAll(); + if (key(ImGuiKey_Z)) shading_ = shift ? Shading::Wire : shading_ == Shading::Solid ? Shading::Rendered : Shading::Solid; + if (key(ImGuiKey_Enter) || key(ImGuiKey_KeypadEnter)) { + if (tool_ == Tool::Draw) finishShape(); + if (tool_ == Tool::Knife && knifePts_.size() >= 2) { + std::vector pts = knifePts_; + knifePts_.clear(); + knifeApply(active_, pts); + } + } + if (mode_ == Mode::Edit) { + if (key(ImGuiKey_M)) mergeNow(); + if (key(ImGuiKey_L)) selectLinked(); + if (key(ImGuiKey_P)) separateSelected(); + if (key(ImGuiKey_1)) elem_ = Elem::Vertex, clearElements(); + if (key(ImGuiKey_2)) elem_ = Elem::Edge, clearElements(); + if (key(ImGuiKey_3)) elem_ = Elem::Face, clearElements(); + } + if (key(ImGuiKey_H)) + for (Object& o : scene_.objects) + if (selected_.count(o.id) && mode_ == Mode::Object) o.visible = false; + if (key(ImGuiKey_Keypad1)) setView(shift ? View::Back : View::Front); + if (key(ImGuiKey_Keypad3)) setView(shift ? View::Right : View::Left); + if (key(ImGuiKey_Keypad7)) setView(shift ? View::Bottom : View::Top); + if (key(ImGuiKey_Keypad5)) view_ == View::Perspective ? setView(View::Front) : leaveFlatView(); + } + + // ───────────────────────────── 3D ───────────────────────────── + + // Solid: a plain grey room and soft studio light, no shadows or effects — for seeing shapes. + // Rendered: sky, sun, shadows and effects. + void applyShading() { + const Look& k = scene_.look; + const std::string want = + shading() == Shading::Rendered + ? std::format("r_ssao {}; r_bloom {}; r_vignette {}; r_exposure {}; r_saturation {}; r_sharpen {}; r_shadowsoft {}; r_gi 1; r_fog 0; r_skycolor \"{} {} {}\"", k.ao, + k.bloom, k.vignette, k.exposure, k.saturation, k.sharpen, k.shadowSoft, k.background.r, k.background.g, k.background.b) + : std::string("r_ssao 0; r_bloom 0; r_vignette 0; r_exposure 1; r_saturation 1; r_sharpen 0; r_gi 0; r_fog 0; r_skycolor \"0.2 0.2 0.215\""); + if (want == appliedLook_) return; + appliedLook_ = want; + keel::execute(want); + } + // Painted, and shown as painted (with rounding or smoothing added since, it isn't). + bool painted(const Object& o) const { return o.picture && o.uvsFit() && !o.hasFinish(); } + + keel::ModelId modelOf(const Object& o) { + const bool editingThis = mode_ == Mode::Edit && o.id == active_; + const uint64_t want = (versions_[o.id] * 2 + editingThis) * 2 + painted(o); + auto it = models_.find(o.id); + if (it != models_.end() && it->second.second == want && versions_[o.id]) return it->second.first; + const Mesh& mesh = shownMesh(o); + keel::KmdlModel km; + keel::KmdlMaterial mat{}; + mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = mat.baseColor[3] = 1.0f; + const bool withPicture = painted(o); + if (withPicture) std::snprintf(mat.texture, sizeof mat.texture, "fry:paint:%u", o.id); + km.materials.push_back(mat); + const auto normals = cornerNormals(mesh, o.smooth, o.smoothAngle); + for (size_t f = 0; f < mesh.faces.size(); ++f) { + const auto& face = mesh.faces[f]; + for (const auto& tri : triangulate(mesh, int(f))) + for (int k = 0; k < 3; ++k) { + const size_t corner = size_t(std::find(face.begin(), face.end(), tri[size_t(k)]) - face.begin()); + const glm::vec3 p = mesh.verts[size_t(tri[size_t(k)])], n = normals[f][corner]; + km.indices.push_back(uint32_t(km.vertices.size())); + const glm::vec2 uv = withPicture ? o.uvs[f][corner] : glm::vec2(0.0f); + km.vertices.push_back({{p.x, p.y, p.z}, {n.x, n.y, n.z}, {uv.x, uv.y}, {0, 0}}); + } + } + if (km.vertices.empty()) km.vertices.push_back({}), km.indices = {0, 0, 0}; + km.submeshes.push_back({0, uint32_t(km.indices.size()), 0}); + const glm::vec3 lo = boundsMin(mesh), hi = boundsMax(mesh); + std::copy_n(&lo.x, 3, km.header.boundsMin); + std::copy_n(&hi.x, 3, km.header.boundsMax); + const keel::ModelId id = r_->updateModel(std::format("fry:object{}", o.id), km); + models_[o.id] = {id, want}; + return id; + } + + const Picture& picture(const Reference& ref) { + auto it = pictures_.find(ref.path); + if (it != pictures_.end()) return it->second; + Picture& p = pictures_[ref.path]; + int w = 0, h = 0, n = 0; + if (stbi_uc* px = stbi_load(ref.path.c_str(), &w, &h, &n, 4)) { + r_->createTexture("fry:picture:" + ref.path, uint32_t(w), uint32_t(h), px); + stbi_image_free(px); + p.ok = true, p.aspect = float(h) / float(std::max(w, 1)); + } else { + say("Couldn't load the picture " + ref.path); + } + return p; + } + + void loadLogo() { + if (logoTried_) return; + logoTried_ = true; + if (auto png = keel::fs::read("branding/logo.png")) { + int w = 0, h = 0, n = 0; + if (stbi_uc* px = stbi_load_from_memory(png->data(), int(png->size()), &w, &h, &n, 4)) { + logo_ = r_->createTexture("fry:logo", uint32_t(w), uint32_t(h), px); + stbi_image_free(px); + haveLogo_ = true; + } + } + } + void logoImage(float size) { + if (haveLogo_) ImGui::Image(ImTextureID(r_->uiTexture(logo_)), ImVec2(size, size)); + } + + float gridStep() const { return std::pow(10.0f, std::floor(std::log10(std::max(viewHeight() / 4.0f, 1e-4f)))); } + + void draw3D(keel::Renderer& r) { + keel::Camera cam; + cam.origin = eye_ * kUnits, cam.yaw = yaw_, cam.pitch = pitch_, cam.fovY = fov_, cam.shift = shift_; + r.setCamera(cam); + const Look& look = scene_.look; + const bool rendered = shading() == Shading::Rendered; + const float turn = glm::radians(look.sunTurn), up = glm::radians(look.sunHeight); + r.setSun(rendered && look.sunStrength > 0, glm::vec3(std::cos(up) * std::cos(turn), std::cos(up) * std::sin(turn), std::sin(up)), look.sunColor * look.sunStrength, true); + keel::Renderer::Atmosphere atm; + atm.sky = rendered && look.sky ? "skies/day.ksky" : ""; + atm.fogDensity = 0.0f; + r.setAtmosphere(atm); + // Rendered: light from the sky all round. Solid: a studio's — bright from above and the + // front-left, darker below, so every side of a shape reads differently. + const keel::ModelLighting light = + rendered ? keel::ModelLighting{{{0.22f, 0.22f, 0.24f}, {0.13f, 0.13f, 0.15f}, {0.2f, 0.2f, 0.22f}, {0.16f, 0.16f, 0.18f}, {0.3f, 0.31f, 0.35f}, {0.06f, 0.06f, 0.06f}}, 1.0f} + : keel::ModelLighting{{{0.5f, 0.49f, 0.47f}, {0.2f, 0.21f, 0.24f}, {0.72f, 0.71f, 0.69f}, {0.3f, 0.31f, 0.34f}, {0.95f, 0.95f, 0.95f}, {0.08f, 0.08f, 0.09f}}, 0.0f}; + const glm::mat4 toUnits = glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)); + // Painted pictures: laid out again if the shape's faces changed, and sent to the card. + for (Object& o : scene_.objects) + if (o.picture && !o.uvsFit() && !o.hasFinish()) o.uvs = autoUnwrap(o.mesh), touched(o.id); + for (const Object& o : scene_.objects) + if (o.picture) uploadPicture(o, !(paint_.active && paint_.object == o.id)); + if (shading() != Shading::Wire) + for (const Object& o : scene_.objects) { + if (!o.visible) continue; + const glm::vec4 tint = painted(o) ? glm::vec4(1.0f) : glm::vec4(glm::pow(o.color, glm::vec3(2.2f)), 1.0f); // (colours are picked as seen: sRGB) + r.drawModel(modelOf(o), toUnits * objectMatrix(o), tint, nullptr, &light, rendered); + } + // Rendered: a ground to stand on and catch the shadows (not part of what you make). + if (rendered && look.ground) { + if (!haveGround_) { + haveGround_ = true; + keel::KmdlModel km; + keel::KmdlMaterial mat{}; + mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = mat.baseColor[3] = 1.0f; + km.materials.push_back(mat); + const float h = 500.0f; + for (glm::vec2 c : {glm::vec2(-h, -h), glm::vec2(h, -h), glm::vec2(h, h), glm::vec2(-h, h)}) km.vertices.push_back({{c.x, c.y, -0.0005f}, {0, 0, 1}, {0, 0}, {0, 0}}); + km.indices = {0, 1, 2, 0, 2, 3}; + km.submeshes.push_back({0, 6, 0}); + ground_ = r.updateModel("fry:ground", km); + } + r.drawModel(ground_, toUnits, glm::vec4(glm::pow(look.groundColor, glm::vec3(2.2f)), 1.0f), nullptr, &light, false); + } + // Reference pictures: flat, see-through, a little behind the plane through the middle. + for (const Reference& ref : scene_.references) { + if (!ref.visible) continue; + const Picture& pic = picture(ref); + if (!pic.ok) continue; + const auto c = referenceCorners(ref, pic.aspect); + const glm::vec3 n = glm::normalize(glm::cross(c[1] - c[0], c[3] - c[0])); + keel::KmdlModel km; + keel::KmdlMaterial mat{}; + std::snprintf(mat.texture, sizeof mat.texture, "fry:picture:%s", ref.path.c_str()); + mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = 1.0f, mat.baseColor[3] = 1.0f; + km.materials.push_back(mat); + const glm::vec2 uv[4] = {{0, 1}, {1, 1}, {1, 0}, {0, 0}}; + for (int side = 0; side < 2; ++side) // (both sides: seen from behind too) + for (int k : {0, 1, 2, 0, 2, 3}) { + const int i = side ? (3 - k + 1) % 4 : k; + const glm::vec3 p = c[size_t(i)] - n * 0.001f, nn = side ? -n : n; + km.indices.push_back(uint32_t(km.vertices.size())); + km.vertices.push_back({{p.x, p.y, p.z}, {nn.x, nn.y, nn.z}, {uv[i].x, uv[i].y}, {0, 0}}); + } + km.submeshes.push_back({0, uint32_t(km.indices.size()), 0}); + const keel::ModelId id = r.updateModel(std::format("fry:ref{}", ref.id), km); + const keel::ModelLighting flat{{glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f)}, 0.0f}; + r.drawModel(id, toUnits, glm::vec4(1.0f, 1.0f, 1.0f, ref.opacity), nullptr, &flat, false); + } + // The grid on the work plane (the ground, or what a flat view looks at), and the axes. + if (tab_ == Tab::Render ? renderGrid_ : showGrid_) { + glm::vec3 p, n; + workPlane(p, n); + const glm::vec3 u = n.z > 0.5f ? glm::vec3(1, 0, 0) : n.x > 0.5f ? glm::vec3(0, 1, 0) : glm::vec3(1, 0, 0); + const glm::vec3 v = glm::cross(n, u); + const float step = gridStep(); + const int count = 20; + const glm::vec3 centre = target_ - n * glm::dot(target_, n); + const float cu = snapTo(glm::dot(centre, u), step * 10), cv = snapTo(glm::dot(centre, v), step * 10); + const float shade = rendered ? 0.0f : 1.0f; // (dark lines on the sunny ground, light ones in the grey room) + for (int i = -count; i <= count; ++i) + for (int dir = 0; dir < 2; ++dir) { + const glm::vec3 along = dir ? u : v, across = dir ? v : u; + const float at = (dir ? cv : cu) + float(i) * step, mid = dir ? cu : cv; + const glm::vec3 a = across * at + along * (mid - count * step), b = across * at + along * (mid + count * step); + const bool major = std::abs(snapTo(at, step * 10) - at) < step * 0.01f, axis = std::abs(at) < step * 0.01f; + glm::vec4 col = glm::vec4(glm::vec3(shade), major ? (rendered ? 0.35f : 0.13f) : (rendered ? 0.16f : 0.06f)); + if (axis) col = dir ? (n.x > 0.5f ? glm::vec4(0.3f, 0.75f, 0.25f, 0.9f) : glm::vec4(0.9f, 0.25f, 0.25f, 0.9f)) + : (n.z > 0.5f ? glm::vec4(0.3f, 0.75f, 0.25f, 0.9f) : glm::vec4(0.25f, 0.45f, 0.95f, 0.9f)); + const float w = pixelSize(target_) * (major || axis ? 1.6f : 1.0f) * kUnits; + r.drawStreak(a * kUnits, b * kUnits, w, col, keel::kWhiteTexture); + } + } + if (tab_ == Tab::Print) drawPrintBed(r, light); + } + + // (the Print tab's: the printer's plate and overhangs) + // On top of the scene: outlines and wires, points, the gizmo, the selection shapes. + void overlays() { + ImDrawList* dl = ImGui::GetBackgroundDrawList(); + dl->PushClipRect(viewMin_, viewMax_); + const ImGuiIO& io = ImGui::GetIO(); + // Every edge of a mesh (X-ray, wireframe), or just the edges the eye can see. + auto lines = [&](const Object& o, const Mesh& mesh, ImU32 col, float width, bool seenOnly) { + const glm::mat4 m = objectMatrix(o); + const std::vector* vis = seenOnly && !xray_ ? &visiblePoints(o) : nullptr; + std::vector s(mesh.verts.size()); + std::vector ok(mesh.verts.size()); + for (size_t i = 0; i < s.size(); ++i) ok[i] = project(glm::vec3(m * glm::vec4(mesh.verts[i], 1.0f)), s[i]); + for (auto [a, b] : edges(mesh)) { + if (vis && !((*vis)[size_t(a)] && (*vis)[size_t(b)])) continue; + if (ok[size_t(a)] && ok[size_t(b)]) dl->AddLine(s[size_t(a)], s[size_t(b)], col, width); + } + }; + // The outline round a selected object (where it turns away from you, and open edges). + auto outline = [&](const Object& o, ImU32 col, float width) { + const Mesh& mesh = shownMesh(o); + const glm::mat4 m = objectMatrix(o); + std::vector w(mesh.verts.size()); + for (size_t i = 0; i < w.size(); ++i) w[i] = glm::vec3(m * glm::vec4(mesh.verts[i], 1.0f)); + std::map, int> side; // bit 1: a face towards you, bit 2: away; + 4 each face + for (size_t f = 0; f < mesh.faces.size(); ++f) { + const auto& fv = mesh.faces[f]; + glm::vec3 n(0.0f); + for (size_t i = 0; i < fv.size(); ++i) n += glm::cross(w[size_t(fv[i])], w[size_t(fv[(i + 1) % fv.size()])]); + const int facing = glm::dot(n, eye_ - w[size_t(fv[0])]) > 0 ? 1 : 2; + for (size_t i = 0; i < fv.size(); ++i) { + int& s = side[{std::min(fv[i], fv[(i + 1) % fv.size()]), std::max(fv[i], fv[(i + 1) % fv.size()])}]; + s = (s | facing) + 4; + } + } + for (auto [e, s] : side) { + const bool silhouette = (s & 3) == 3, open = (s >> 2) == 1 && (s & 1); + ImVec2 a, b; + if ((silhouette || open) && project(w[size_t(e.first)], a) && project(w[size_t(e.second)], b)) dl->AddLine(a, b, col, width); + } + }; + const bool clean = capturing(); // (a picture being saved: no outlines or gizmos) + Object* ed = editing(); + for (const Object& o : scene_.objects) { + if (!o.visible) continue; + const bool sel = selected_.count(o.id) > 0 && !clean && tab_ != Tab::Paint; + if (ed && o.id == ed->id) continue; + if (shading() == Shading::Wire || xray_) lines(o, shownMesh(o), sel ? kAccent : IM_COL32(200, 200, 205, 150), sel ? 1.6f : 1.0f, false); + else if (sel) outline(o, kAccent, 2.5f); + } + if (tab_ == Tab::Paint) { + if (Object* so = find(ledit_.object); so && editLayer() && editLayer()->sticker) { // the sticker's frame and handles + std::array hw; + ImVec2 q[6]; + bool ok = stickerHandles(*so, *editLayer(), hw); + for (int i = 0; ok && i < 6; ++i) ok = project(hw[size_t(i)], q[i]); + if (ok) { + const int hot = ledit_.drag >= 0 ? ledit_.drag : stickerHandle(*so, *editLayer(), io.MousePos); + dl->AddPolyline(q, 4, hot == 4 ? kHot : kAccent, ImDrawFlags_Closed, 2.0f); + const ImVec2 top((q[0].x + q[1].x) * 0.5f, (q[0].y + q[1].y) * 0.5f); + dl->AddLine(top, q[4], kAccent, 1.5f); + dl->AddCircleFilled(q[4], 7.0f, hot == 5 ? kHot : IM_COL32(255, 255, 255, 240)); + dl->AddCircle(q[4], 7.0f, IM_COL32(20, 20, 25, 255)); + for (int i = 0; i < 4; ++i) { + dl->AddRectFilled(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), hot == i ? kHot : IM_COL32(255, 255, 255, 240)); + dl->AddRect(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), IM_COL32(20, 20, 25, 255)); + } + } + } + if (mouseInView(io) && !editLayer() && paintTool_ != PaintTool::Picture) + dl->AddCircle(io.MousePos, brushPx_, paintTool_ == PaintTool::Fill || paintTool_ == PaintTool::Picker ? IM_COL32(255, 255, 255, 90) : IM_COL32(255, 255, 255, 200), 40, 1.5f); + dl->PopClipRect(); + return; + } + if (tab_ == Tab::Layout) { + layoutOverlay(dl); + dl->PopClipRect(); + return; + } + if (tab_ == Tab::Print && !clean) printOverlay(dl, io); + if (clean) { + dl->PopClipRect(); + return; + } + if (ed) { + if (ed->hasFinish() && ed->mirror && shading() != Shading::Wire) // (the mirrored half: faint) + lines(*ed, shownMesh(*ed), IM_COL32(20, 20, 25, 70), 1.0f, false); + lines(*ed, ed->mesh, shading() == Shading::Wire ? IM_COL32(230, 230, 235, 230) : IM_COL32(12, 12, 15, 235), 1.3f, true); + } + if (Object* o = ed) { + const glm::mat4 m = objectMatrix(*o); + if (elem_ == Elem::Vertex) { + for (size_t v = 0; v < o->mesh.verts.size(); ++v) { + ImVec2 s; + const bool sel = selVerts_.count(int(v)); + if (!project(glm::vec3(m * glm::vec4(o->mesh.verts[v], 1.0f)), s) || (!sel && !pointSeen(*o, int(v)))) continue; + dl->AddRectFilled(ImVec2(s.x - 3, s.y - 3), ImVec2(s.x + 3, s.y + 3), sel ? kAccent : IM_COL32(15, 15, 18, 255)); + } + } else if (elem_ == Elem::Edge) { + for (auto [a, b] : selEdges_) { + ImVec2 sa, sb; + if (a < int(o->mesh.verts.size()) && b < int(o->mesh.verts.size()) && project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(a)], 1.0f)), sa) && + project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(b)], 1.0f)), sb)) + dl->AddLine(sa, sb, kAccent, 3.0f); + } + } else { // the selected faces the eye can see: an orange skin, a dot in the middle + const glm::mat3 nm = glm::transpose(glm::inverse(glm::mat3(m))); + for (int f : selFaces_) { + if (f >= int(o->mesh.faces.size())) continue; + const glm::vec3 c(m * glm::vec4(faceCenter(o->mesh, f), 1.0f)); + const bool front = glm::dot(nm * faceNormal(o->mesh, f), eye_ - c) > 0; + if (!xray_ && !front) continue; + const auto& fv = o->mesh.faces[size_t(f)]; + if (!xray_ && !std::all_of(fv.begin(), fv.end(), [&](int v) { return pointSeen(*o, v); }) && !visible(*o, c)) continue; + std::vector poly(fv.size()); + bool ok = true; + for (size_t k = 0; k < fv.size(); ++k) ok &= project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(fv[k])], 1.0f)), poly[k]); + if (ok) dl->AddConcavePolyFilled(poly.data(), int(poly.size()), IM_COL32(255, 120, 40, 70)); + ImVec2 s; + if (project(c, s)) dl->AddCircleFilled(s, 3.5f, kAccent); + } + } + // The loop cut to be: across each face of the ring. + if (tool_ == Tool::LoopCut && cutHover_.first >= 0) + for (const auto& q : ringQuads(o->mesh, cutHover_)) + for (int c = 1; c <= cuts_; ++c) { + const float t = float(c) / float(cuts_ + 1); + const glm::vec3 a = glm::mix(o->mesh.verts[size_t(q[0])], o->mesh.verts[size_t(q[1])], t), b = glm::mix(o->mesh.verts[size_t(q[3])], o->mesh.verts[size_t(q[2])], t); + ImVec2 sa, sb; + if (project(glm::vec3(m * glm::vec4(a, 1.0f)), sa) && project(glm::vec3(m * glm::vec4(b, 1.0f)), sb)) dl->AddLine(sa, sb, kHot, 2.5f); + } + } + // The knife's line: its points, on to the mouse; cutting through, on past the ends. + if (tool_ == Tool::Knife && (!knifePts_.empty() || knifeDown_)) { + std::vector pts = knifePts_; + if (knifePts_.empty()) pts.push_back(knifePress_); + if (mouseInView(io) || knifeDown_) pts.push_back(knifeEnd(io)); + for (size_t i = 0; i + 1 < pts.size(); ++i) dl->AddLine(pts[i], pts[i + 1], kHot, 2.0f); + if (knifeThrough_ && pts.size() >= 2) + for (int end = 0; end < 2; ++end) { + const ImVec2 a = end ? pts.back() : pts.front(), b = end ? pts[pts.size() - 2] : pts[1]; + const float dx = a.x - b.x, dy = a.y - b.y, l = std::max(std::hypot(dx, dy), 1e-3f); + for (int k = 0; k < 60; k += 2) + dl->AddLine(ImVec2(a.x + dx / l * float(k) * 12, a.y + dy / l * float(k) * 12), ImVec2(a.x + dx / l * float(k + 1) * 12, a.y + dy / l * float(k + 1) * 12), + (kHot & 0x00FFFFFF) | 0x90000000, 1.5f); + } + for (const ImVec2& p : knifePts_) dl->AddCircleFilled(p, 4.0f, kHot); + } + // The hole to be. + if (tool_ == Tool::Hole && (mouseInView(io) || hole_.active)) { + const auto ring = holePreview(io); + std::vector pts; + for (const glm::vec3& w : ring) { + ImVec2 sp; + if (project(w, sp)) pts.push_back(sp); + } + if (pts.size() >= 3) dl->AddPolyline(pts.data(), int(pts.size()), hole_.active ? kHot : IM_COL32(255, 255, 255, 200), ImDrawFlags_Closed, 2.0f); + } + if (boxing_ && std::hypot(io.MousePos.x - boxStart_.x, io.MousePos.y - boxStart_.y) > 4) { + dl->AddRectFilled(boxStart_, io.MousePos, IM_COL32(255, 140, 60, 30)); + dl->AddRect(boxStart_, io.MousePos, kAccent); + } + if (lassoing_ && lasso_.size() > 1) dl->AddPolyline(lasso_.data(), int(lasso_.size()), kAccent, ImDrawFlags_Closed, 1.5f); + if (selShape_ == SelShape::Brush && tool_ == Tool::Select && mode_ == Mode::Edit && mouseInView(io)) + dl->AddCircle(io.MousePos, brushRadius_, painting_ ? kAccent : IM_COL32(255, 255, 255, 160), 32, 1.5f); + if (tool_ == Tool::Draw) { + ImVec2 prev, s; + for (size_t i = 0; i < drawPts_.size(); ++i) { + if (!project(drawPts_[i], s)) continue; + if (i) dl->AddLine(prev, s, kAccent, 2.0f); + dl->AddCircleFilled(s, i == 0 ? 6.0f : 4.0f, i == 0 ? IM_COL32(255, 255, 255, 255) : kAccent); + prev = s; + } + if (mouseInView(io)) + if (auto p = drawPoint(io); p && project(*p, s)) { + if (!drawPts_.empty()) dl->AddLine(prev, s, IM_COL32(255, 160, 90, 180), 1.5f); + dl->AddCircle(s, 5.0f, IM_COL32(255, 255, 255, 220), 0, 1.5f); + } + } + gizmo(dl, io); + dl->PopClipRect(); + } + + void gizmo(ImDrawList* dl, const ImGuiIO& io) { + const int hot = mouseInView(io) || drag_.active ? hoverHandle(io.MousePos) : -1; + if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { + glm::vec3 at, n; + if (!faceHandle(at, n)) return; + ImVec2 a, b; + if (!project(at, a) || !project(at + n * pixelSize(at) * 90.0f, b)) return; + const ImU32 col = hot == 0 ? kHot : IM_COL32(255, 255, 255, 255); + dl->AddLine(a, b, col, 3.0f); + if (tool_ == Tool::Extrude) dl->AddCircleFilled(b, 7.0f, col); + else dl->AddRectFilled(ImVec2(b.x - 6, b.y - 6), ImVec2(b.x + 6, b.y + 6), col); + return; + } + if (tool_ == Tool::Bevel) { + ImVec2 c, h; + if (!bevelHandle(c, h)) return; + const ImU32 col = hot == 0 ? kHot : IM_COL32(255, 255, 255, 255); + dl->AddLine(c, h, (col & 0x00FFFFFF) | 0x90000000, 2.0f); + dl->AddCircleFilled(h, 9.0f, col); + drawIcon(dl, Icon::Bevel, ImVec2(h.x - 7, h.y - 7), 14.0f, IM_COL32(30, 30, 35, 255)); + return; + } + if (tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) return; + if (!hasSelection()) return; + const Axes axes = drag_.active ? drag_.axes : gizmoAxes(); + if (tool_ == Tool::Scale) { // the box round the selection, a handle on each side + const auto [lo, hi] = selectionBox(axes); + for (int e = 0; e < 12; ++e) { + const int k = e / 4, u = (k + 1) % 3, v = (k + 2) % 3; + glm::vec3 a, b; + a[k] = lo[k], b[k] = hi[k]; + a[u] = b[u] = e & 1 ? hi[u] : lo[u]; + a[v] = b[v] = e & 2 ? hi[v] : lo[v]; + ImVec2 sa, sb; + if (project(fromFrame(axes, a), sa) && project(fromFrame(axes, b), sb)) dl->AddLine(sa, sb, IM_COL32(255, 255, 255, 110), 1.0f); + } + for (int h : {0, 1, 2, 4, 5, 6, 3}) { + ImVec2 s; + if (!project(scaleHandle(axes, lo, hi, h), s)) continue; + const ImU32 col = hot == h ? kHot : h == 3 ? IM_COL32(255, 255, 255, 235) : kAxisCol[h < 3 ? h : h - 4]; + dl->AddRectFilled(ImVec2(s.x - 6, s.y - 6), ImVec2(s.x + 6, s.y + 6), col); + dl->AddRect(ImVec2(s.x - 6, s.y - 6), ImVec2(s.x + 6, s.y + 6), IM_COL32(20, 20, 25, 200)); + } + return; + } + const glm::vec3 p = drag_.active ? (tool_ == Tool::Rotate ? drag_.pivot : selectionMiddle(axes)) : (tool_ == Tool::Rotate ? rotatePivot(axes) : selectionMiddle(axes)); + ImVec2 c; + if (!project(p, c)) return; + const float len = pixelSize(p) * 90.0f; + if (tool_ == Tool::Rotate) { + for (int k = 0; k < 3; ++k) { + const glm::vec3 u = glm::normalize(glm::cross(axes[size_t(k)], std::abs(axes[size_t(k)].z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0))); + const glm::vec3 v = glm::cross(axes[size_t(k)], u); + ImVec2 prev; + bool havePrev = false; + for (int i = 0; i <= 64; ++i) { + const float a = 2 * kPi * float(i) / 64.0f; + const glm::vec3 w = p + (u * std::cos(a) + v * std::sin(a)) * len; + ImVec2 s; + if (!project(w, s)) { + havePrev = false; + continue; + } + const bool front = glm::dot(w - p, eye_ - p) > -0.2f * len * glm::length(eye_ - p); + if (havePrev) dl->AddLine(prev, s, hot == k ? kHot : (front ? kAxisCol[k] : (kAxisCol[k] & 0x00FFFFFF) | 0x50000000), hot == k ? 3.5f : 2.5f); + prev = s, havePrev = true; + } + } + dl->AddCircle(c, 102.0f, hot == 3 ? kHot : IM_COL32(255, 255, 255, 160), 64, 2.0f); + return; + } + for (int k = 0; k < 3; ++k) { + ImVec2 e; + if (!project(p + axes[size_t(k)] * len, e)) continue; + const ImU32 col = hot == k ? kHot : kAxisCol[k]; + dl->AddLine(c, e, col, 3.0f); + const float dx = e.x - c.x, dy = e.y - c.y, l = std::max(std::hypot(dx, dy), 1.0f); + const ImVec2 dir(dx / l, dy / l), side(-dir.y, dir.x); + dl->AddTriangleFilled(ImVec2(e.x + dir.x * 14, e.y + dir.y * 14), ImVec2(e.x + side.x * 6, e.y + side.y * 6), ImVec2(e.x - side.x * 6, e.y - side.y * 6), col); + } + dl->AddRect(ImVec2(c.x - 8, c.y - 8), ImVec2(c.x + 8, c.y + 8), hot == 3 ? kHot : IM_COL32(255, 255, 255, 230), 0, 0, 2.0f); + } + + // ───────────────────────────── panels ───────────────────────────── + + void setupUi() { + if (iniPath_.empty()) { + iniPath_ = keel::prefPath() + "layout_v3.ini"; + ImGui::GetIO().IniFilename = iniPath_.c_str(); + if (stdfs::exists(iniPath_)) ImGui::LoadIniSettingsFromDisk(iniPath_.c_str()); + } + if (styled_) return; + styled_ = true; + ImGuiStyle& st = ImGui::GetStyle(); + st.FramePadding = ImVec2(7, 5), st.ItemSpacing = ImVec2(7, 6), st.WindowPadding = ImVec2(9, 9); + st.WindowRounding = 5, st.FrameRounding = 4, st.TabRounding = 4, st.GrabRounding = 4, st.ScrollbarSize = 12; + st.FontScaleDpi = 0.9f; + ImVec4* c = st.Colors; // dark, with Fry3DX's hot orange + const ImVec4 orange(1.0f, 0.47f, 0.16f, 1.0f), dim(0.75f, 0.33f, 0.1f, 1.0f); + c[ImGuiCol_WindowBg] = ImVec4(0.11f, 0.11f, 0.12f, 0.97f); + c[ImGuiCol_ChildBg] = ImVec4(0, 0, 0, 0); + c[ImGuiCol_PopupBg] = ImVec4(0.12f, 0.12f, 0.13f, 0.98f); + c[ImGuiCol_MenuBarBg] = ImVec4(0.09f, 0.09f, 0.1f, 1.0f); + c[ImGuiCol_TitleBg] = c[ImGuiCol_TitleBgActive] = c[ImGuiCol_TitleBgCollapsed] = ImVec4(0.09f, 0.09f, 0.1f, 1.0f); + c[ImGuiCol_FrameBg] = ImVec4(0.18f, 0.18f, 0.2f, 1.0f); + c[ImGuiCol_FrameBgHovered] = ImVec4(0.24f, 0.24f, 0.27f, 1.0f); + c[ImGuiCol_FrameBgActive] = ImVec4(0.28f, 0.28f, 0.31f, 1.0f); + c[ImGuiCol_Button] = ImVec4(0.2f, 0.2f, 0.22f, 1.0f); + c[ImGuiCol_ButtonHovered] = ImVec4(0.3f, 0.3f, 0.33f, 1.0f); + c[ImGuiCol_ButtonActive] = dim; + c[ImGuiCol_Header] = ImVec4(0.22f, 0.22f, 0.25f, 1.0f); + c[ImGuiCol_HeaderHovered] = ImVec4(0.3f, 0.3f, 0.33f, 1.0f); + c[ImGuiCol_HeaderActive] = dim; + c[ImGuiCol_CheckMark] = c[ImGuiCol_SliderGrabActive] = orange; + c[ImGuiCol_SliderGrab] = dim; + c[ImGuiCol_Tab] = ImVec4(0.14f, 0.14f, 0.15f, 1.0f); + c[ImGuiCol_TabHovered] = ImVec4(0.3f, 0.3f, 0.33f, 1.0f); + c[ImGuiCol_TabSelected] = ImVec4(0.2f, 0.2f, 0.22f, 1.0f); + c[ImGuiCol_TabSelectedOverline] = orange; + c[ImGuiCol_TabDimmedSelected] = ImVec4(0.17f, 0.17f, 0.19f, 1.0f); + c[ImGuiCol_SeparatorHovered] = c[ImGuiCol_SeparatorActive] = c[ImGuiCol_ResizeGripActive] = orange; + c[ImGuiCol_DockingPreview] = ImVec4(1.0f, 0.47f, 0.16f, 0.5f); + c[ImGuiCol_TextSelectedBg] = ImVec4(1.0f, 0.47f, 0.16f, 0.35f); + } + + void defaultLayout(ImGuiID dock) { + layoutDone_ = true; + ImGuiDockNode* node = ImGui::DockBuilderGetNode(dock); + if (node && (node->IsSplitNode() || node->Windows.Size)) return; // (the user's own, from the .ini) + ImGui::DockBuilderRemoveNodeChildNodes(dock); + ImGuiID center = dock; + ImGuiID left = ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.14f, nullptr, ¢er); + const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.5f, nullptr, &left); + ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.22f, nullptr, ¢er); + const ImGuiID rightBottom = ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.66f, nullptr, &right); + const ImGuiID bottom = ImGui::DockBuilderSplitNode(center, ImGuiDir_Down, 0.04f, nullptr, ¢er); + const ImGuiID picture = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.45f, nullptr, ¢er); // (the Paint tab's) + ImGui::DockBuilderDockWindow("Picture", picture); + ImGui::DockBuilderDockWindow("Toolbox", left); + ImGui::DockBuilderDockWindow("Tool options", leftBottom); + ImGui::DockBuilderDockWindow("Layers", right); + ImGui::DockBuilderDockWindow("Properties", rightBottom); + ImGui::DockBuilderDockWindow("Status", bottom); + if (ImGuiDockNode* b = ImGui::DockBuilderGetNode(bottom)) b->SetLocalFlags(ImGuiDockNodeFlags_NoTabBar); + ImGui::DockBuilderFinish(dock); + } + + void menuBar() { + if (!ImGui::BeginMainMenuBar()) return; + if (haveLogo_) { + const float h = ImGui::GetFrameHeight() - 4; + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 1); + logoImage(h); + } + ImGui::TextColored(ImVec4(1.0f, 0.47f, 0.16f, 1.0f), "Fry3DX"); + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("New", "Ctrl+N")) whenSaved(2); + if (ImGui::MenuItem("Open...", "Ctrl+O")) whenSaved(3); + if (ImGui::MenuItem("Save", "Ctrl+S")) path_.empty() ? dialog(2) : void(saveFile(path_)); + if (ImGui::MenuItem("Save as...", "Ctrl+Shift+S")) dialog(2); + ImGui::Separator(); + if (ImGui::MenuItem("Export (GLB, glTF, OBJ, STL, PLY)...", "Ctrl+E")) dialog(3); + if (ImGui::BeginMenu("Autosave")) { + static const int every[] = {1, 2, 5, 10, 0}; + for (int m : every) + if (ImGui::MenuItem(m ? std::format("Every {} minute{}", m, m > 1 ? "s" : "").c_str() : "Off", nullptr, fry_autosave.i() == m)) + keel::execute(std::format("fry_autosave {}", m)); + ImGui::Separator(); + if (ImGui::MenuItem("Recover autosaved work...")) findAutosaves(), showRecover_ = !recover_.empty(); + ImGui::EndMenu(); + } + if (ImGui::MenuItem("Save a picture of the view...", "F12")) dialog(5); + ImGui::Separator(); + if (ImGui::MenuItem("Quit", "Ctrl+Q")) whenSaved(1); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Edit")) { + if (ImGui::MenuItem("Undo", "Ctrl+Z", false, !undo_.empty())) undo(); + if (ImGui::MenuItem("Redo", "Ctrl+Y", false, !redo_.empty())) redo(); + ImGui::Separator(); + if (ImGui::MenuItem("Duplicate", "Ctrl+D", false, mode_ == Mode::Object)) duplicateSelected(); + if (ImGui::MenuItem("Join into one", "Ctrl+J", false, mode_ == Mode::Object && selected_.size() > 1)) joinSelected(); + if (ImGui::MenuItem("Delete", "Del")) deleteSelected(); + ImGui::Separator(); + if (ImGui::MenuItem(mode_ == Mode::Edit ? "Stop editing the shape" : "Edit the shape", "Tab")) mode_ == Mode::Edit ? leaveEdit() : enterEdit(); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Select")) { + if (ImGui::MenuItem("All", "Ctrl+A")) selectAll(); + if (ImGui::MenuItem("None", "Alt+A")) selectNone(); + if (ImGui::MenuItem("Invert", "Ctrl+I")) invertSelection(); + ImGui::Separator(); + ImGui::BeginDisabled(mode_ != Mode::Edit); + if (ImGui::MenuItem("More (grow)", "Ctrl +")) growSelection(); + if (ImGui::MenuItem("Less (shrink)", "Ctrl -")) shrinkSelection(); + if (ImGui::MenuItem("Everything joined to it", "L")) selectLinked(); + ImGui::MenuItem("A loop: Alt+click or double-click", nullptr, false, false); + ImGui::EndDisabled(); + ImGui::Separator(); + if (ImGui::MenuItem("Select with a box", nullptr, selShape_ == SelShape::Box)) selShape_ = SelShape::Box; + if (ImGui::MenuItem("Select with a brush", nullptr, selShape_ == SelShape::Brush)) selShape_ = SelShape::Brush; + if (ImGui::MenuItem("Select with a lasso", nullptr, selShape_ == SelShape::Lasso)) selShape_ = SelShape::Lasso; + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Add")) { + addMenuItems(); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Object")) { + const bool many = selected_.size() >= 2, any = !selected_.empty(); + if (ImGui::MenuItem("Go to it", "F", false, any)) goTo(selected_); + if (ImGui::MenuItem("Duplicate", "Ctrl+D", false, any)) duplicateSelected(); + if (ImGui::MenuItem("Make copies (in a row, round)...", nullptr, false, any)) showCopies_ = true; + if (ImGui::MenuItem("Group into a folder", "Ctrl+G", false, any)) groupSelected(); + ImGui::Separator(); + ImGui::TextDisabled("Two or more: the last picked is kept"); + if (ImGui::MenuItem("Add them together", "Ctrl+Shift++", false, many)) combineSelected(Combine::Add); + if (ImGui::MenuItem("Take the others out of it", "Ctrl+Shift+-", false, many)) combineSelected(Combine::Subtract); + if (ImGui::MenuItem("Keep only where they overlap", nullptr, false, many)) combineSelected(Combine::Overlap); + if (ImGui::MenuItem("Join (just as they are)", "Ctrl+J", false, many)) joinSelected(); + ImGui::Separator(); + if (ImGui::MenuItem("Make it thick (or hollow)...", nullptr, false, any)) showThicken_ = true; + if (ImGui::MenuItem("Join flat faces side by side", nullptr, false, any)) cleanFlatFaces(); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Shape")) { + const bool ed = mode_ == Mode::Edit; + if (!ed) ImGui::TextDisabled("Edit an object's shape first (Tab)"); + ImGui::BeginDisabled(!ed); + if (ImGui::MenuItem("Extrude", "X")) setTool(Tool::Extrude); + if (ImGui::MenuItem("Inset", "I")) setTool(Tool::Inset); + if (ImGui::MenuItem("Round edges", "B")) setTool(Tool::Bevel); + if (ImGui::MenuItem("Loop cut", "C")) setTool(Tool::LoopCut); + ImGui::Separator(); + if (ImGui::MenuItem("Cut every face in four")) subdivideNow(false); + if (ImGui::MenuItem("Smooth it all (cut and round)")) subdivideNow(true); + if (ImGui::MenuItem("Merge points", "M")) mergeNow(); + if (ImGui::MenuItem("Make a face")) makeFaceNow(); + if (ImGui::MenuItem("Flip faces")) flipNow(); + if (ImGui::MenuItem("Make into its own object", "P")) separateSelected(); + if (ImGui::MenuItem("Delete", "Del")) deleteElements(); + ImGui::EndDisabled(); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("View")) { + if (ImGui::MenuItem("Solid (for modelling)", "Z", shading_ == Shading::Solid)) shading_ = Shading::Solid; + if (ImGui::MenuItem("Rendered (sky, sun, shadows)", "Z", shading_ == Shading::Rendered)) shading_ = Shading::Rendered; + if (ImGui::MenuItem("Wireframe", "Shift+Z", shading_ == Shading::Wire)) shading_ = Shading::Wire; + ImGui::MenuItem("X-ray (see and pick through)", "Alt+Z", &xray_); + ImGui::MenuItem("Grid", nullptr, &showGrid_); + ImGui::MenuItem("Ground (in Rendered)", nullptr, &scene_.look.ground); + ImGui::Separator(); + if (ImGui::MenuItem("Perspective", "Numpad 5", view_ == View::Perspective)) leaveFlatView(); + if (ImGui::MenuItem("Front", "Numpad 1", view_ == View::Front)) setView(View::Front); + if (ImGui::MenuItem("Back", "Shift+Numpad 1", view_ == View::Back)) setView(View::Back); + if (ImGui::MenuItem("Left side", "Numpad 3", view_ == View::Left)) setView(View::Left); + if (ImGui::MenuItem("Right side", "Shift+Numpad 3", view_ == View::Right)) setView(View::Right); + if (ImGui::MenuItem("Top", "Numpad 7", view_ == View::Top)) setView(View::Top); + if (ImGui::MenuItem("Bottom", "Shift+Numpad 7", view_ == View::Bottom)) setView(View::Bottom); + ImGui::Separator(); + if (ImGui::MenuItem("Frame selected", "F")) frameSelected(); + if (ImGui::MenuItem("Frame everything", "Home")) frameAll(); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Help")) { + if (ImGui::MenuItem("Keys and mouse", "F1")) showHelp_ = true; + if (ImGui::MenuItem("About Fry3DX")) showAbout_ = true; + ImGui::EndMenu(); + } + // The tabs: what you're doing. + { + const char* names[] = {"Model", "Paint", "Layout", "Render", "Print"}; + const Icon icons[] = {Icon::Objects, Icon::PaintBrush, Icon::Layout, Icon::Render, Icon::Print}; + const char* tips[] = {"Make the shape (Ctrl+1)", "Paint pictures onto it (Ctrl+2)", "Change how its picture is laid out: move, turn and size its frames (Ctrl+3)", + "Light it and save pictures of it (Ctrl+4)", "Get it ready for a 3D printer: size, checks, the printer's plate (Ctrl+5)"}; + const float tw = 92.0f; + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX() + 20.0f, (ImGui::GetWindowWidth() - tw * 5 - 16) * 0.5f)); + for (int i = 0; i < 5; ++i) { + if (i) ImGui::SameLine(0, 4); + ImGui::PushID(i); + if (wideButton("##tab", icons[i], names[i], int(tab_) == i, tw, tips[i], ImGui::GetFrameHeight())) setTab(Tab(i)); + ImGui::PopID(); + } + } + const std::string file = (path_.empty() ? "Untitled" : stdfs::path(path_).filename().string()) + (dirty_ ? " *" : ""); + ImGui::SameLine(ImGui::GetWindowWidth() - ImGui::CalcTextSize(file.c_str()).x - 16); + ImGui::TextDisabled("%s", file.c_str()); + ImGui::EndMainMenuBar(); + } + + void addMenuItems() { + ImGui::SeparatorText("Shapes"); + for (const ShapeRecipe& r : shapeRecipes()) + if (!r.starter && ImGui::MenuItem(r.name)) addShape(r.id); + ImGui::SeparatorText("Starter models"); + for (const ShapeRecipe& r : shapeRecipes()) + if (r.starter && ImGui::MenuItem(r.name)) addShape(r.id); + ImGui::Separator(); + if (ImGui::MenuItem("Draw a shape", "D")) { + if (mode_ == Mode::Edit) leaveEdit(); + setTool(Tool::Draw); + } + if (ImGui::MenuItem("Reference picture...")) dialog(4); + } + + // A square button with a picture on it (orange when it's the one in use). + bool iconButton(const char* id, Icon icon, bool on, const char* tip, float size = 38.0f) { + const ImVec2 p = ImGui::GetCursorScreenPos(); + const bool hit = ImGui::InvisibleButton(id, ImVec2(size, size)); + const bool hov = ImGui::IsItemHovered(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddRectFilled(p, ImVec2(p.x + size, p.y + size), on ? IM_COL32(196, 86, 26, 255) : hov ? IM_COL32(68, 68, 76, 255) : IM_COL32(40, 40, 46, 255), 6.0f); + drawIcon(dl, icon, ImVec2(p.x + size * 0.14f, p.y + size * 0.14f), size * 0.72f, on ? IM_COL32(255, 255, 255, 255) : IM_COL32(215, 215, 222, 255)); + if (hov && tip) ImGui::SetTooltip("%s", tip); + return hit; + } + // A wide one, with words beside the picture. + bool wideButton(const char* id, Icon icon, const char* label, bool on, float width, const char* tip = nullptr, float h = 30.0f) { + const ImVec2 p = ImGui::GetCursorScreenPos(); + const bool hit = ImGui::InvisibleButton(id, ImVec2(width, h)); + const bool hov = ImGui::IsItemHovered(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddRectFilled(p, ImVec2(p.x + width, p.y + h), on ? IM_COL32(196, 86, 26, 255) : hov ? IM_COL32(68, 68, 76, 255) : IM_COL32(40, 40, 46, 255), 6.0f); + drawIcon(dl, icon, ImVec2(p.x + h * 0.15f, p.y + h * 0.13f), h * 0.74f, IM_COL32(235, 235, 240, 255)); + dl->AddText(ImVec2(p.x + h + 2, p.y + (h - ImGui::GetFontSize()) * 0.5f), IM_COL32(235, 235, 240, 255), label); + if (hov && tip) ImGui::SetTooltip("%s", tip); + return hit; + } + // Buttons in rows, as many as fit. + template + void grid(int count, float size, F each) { + const float gap = 5.0f; + const int perRow = std::max(1, int((ImGui::GetContentRegionAvail().x + gap) / (size + gap))); + for (int i = 0; i < count; ++i) { + if (i % perRow) ImGui::SameLine(0, gap); + each(i); + } + } + + void toolbox() { + ImGui::Begin("Toolbox", nullptr, ImGuiWindowFlags_NoCollapse); + const float w = ImGui::GetContentRegionAvail().x; + if (tab_ == Tab::Paint) { + paintToolbox(w); + return ImGui::End(); + } + if (tab_ == Tab::Layout) { + layoutToolbox(w); + return ImGui::End(); + } + if (tab_ == Tab::Render || tab_ == Tab::Print) { + const Tool tools[] = {Tool::Select, Tool::Move, Tool::Rotate, Tool::Scale}; + grid(4, 38.0f, [&](int i) { + const ToolInfo& t = kTools[int(tools[i])]; + const std::string id = std::format("##{}", t.name), tip = std::format("{} ({})\n{}", t.name, t.key, t.hint); + if (iconButton(id.c_str(), t.icon, tool_ == t.tool, tip.c_str())) setTool(t.tool); + }); + ImGui::Spacing(); + if (tab_ == Tab::Print) printToolbox(w); + else if (wideButton("##save", Icon::Picture, "Save a picture", false, w, "Saves what the view shows, without the panels or gizmos (F12)")) dialog(5); + return ImGui::End(); + } + // The mode: whole objects, or the shape of one. + if (wideButton("##objects", Icon::Objects, "Objects", mode_ == Mode::Object, (w - 5) / 2, "Work with whole objects")) leaveEdit(); + ImGui::SameLine(0, 5); + if (wideButton("##shape", Icon::Shape, "Shape", mode_ == Mode::Edit, (w - 5) / 2, "Edit the selected object's shape: its points, edges and faces (Tab, or double-click it)")) enterEdit(); + if (mode_ == Mode::Edit) { + const Icon icons[] = {Icon::Points, Icon::Edges, Icon::Faces}; + const char* tips[] = {"Points (1)", "Edges (2)", "Faces (3)"}; + const char* names[] = {"Points", "Edges", "Faces"}; + const float third = (w - 10) / 3; + for (int i = 0; i < 3; ++i) { + if (i) ImGui::SameLine(0, 5); + ImGui::PushID(i); + if (wideButton("##elem", icons[i], names[i], int(elem_) == i, third, tips[i])) elem_ = Elem(i), clearElements(); + ImGui::PopID(); + } + } + ImGui::Spacing(); + // The tools, as pictures (hover for the name and key). + std::vector tools; + for (const ToolInfo& t : kTools) + if ((mode_ == Mode::Object && t.objectMode) || (mode_ == Mode::Edit && t.editMode)) tools.push_back(&t); + grid(int(tools.size()) + 1, 38.0f, [&](int i) { + if (i == int(tools.size())) { + if (iconButton("##add", Icon::Add, false, "Add a shape or a starter model")) ImGui::OpenPopup("add"); + return; + } + const ToolInfo& t = *tools[size_t(i)]; + const std::string id = std::format("##{}", t.name), tip = std::format("{} ({})\n{}", t.name, t.key, t.hint); + if (iconButton(id.c_str(), t.icon, tool_ == t.tool, tip.c_str())) setTool(t.tool); + }); + if (ImGui::BeginPopup("add")) { + addMenuItems(); + ImGui::EndPopup(); + } + ImGui::Spacing(); + for (const ToolInfo& t : kTools) + if (t.tool == tool_) ImGui::TextColored(kAccentText, "%s", t.name); + ImGui::End(); + } + + void paintToolbox(float w) { + grid(int(std::size(kPaintTools)), 38.0f, [&](int i) { + const PaintToolInfo& t = kPaintTools[i]; + const std::string id = std::format("##{}", t.name), tip = std::format("{} ({})\n{}", t.name, t.key, t.hint); + if (iconButton(id.c_str(), t.icon, paintTool_ == t.tool, tip.c_str())) paintTool_ = t.tool; + }); + ImGui::Spacing(); + // The colour, big, and a few to pick from. + ImGui::ColorButton("##colour", ImVec4(paintColor_.r, paintColor_.g, paintColor_.b, 1.0f), ImGuiColorEditFlags_NoTooltip, ImVec2(w, 34)); + static const glm::vec3 palette[] = {{0.08f, 0.08f, 0.09f}, {0.95f, 0.95f, 0.95f}, {0.55f, 0.55f, 0.57f}, {0.85f, 0.2f, 0.12f}, {1.0f, 0.55f, 0.1f}, {0.98f, 0.85f, 0.2f}, + {0.3f, 0.7f, 0.25f}, {0.15f, 0.45f, 0.85f}, {0.45f, 0.25f, 0.7f}, {0.45f, 0.3f, 0.18f}, {0.75f, 0.6f, 0.45f}, {0.95f, 0.6f, 0.7f}}; + grid(int(std::size(palette)), 20.0f, [&](int i) { + ImGui::PushID(i); + if (ImGui::ColorButton("##p", ImVec4(palette[i].r, palette[i].g, palette[i].b, 1.0f), 0, ImVec2(20, 20))) paintColor_ = glm::vec4(palette[i], paintColor_.a); + ImGui::PopID(); + }); + for (const PaintToolInfo& t : kPaintTools) + if (t.tool == paintTool_) ImGui::TextColored(kAccentText, "%s", t.name); + } + + void paintOptions(float w) { + for (const PaintToolInfo& t : kPaintTools) + if (t.tool == paintTool_) ImGui::SeparatorText(t.name); + if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser) { + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##size", &brushPx_, 1.0f, 300.0f, "size %.0f px", ImGuiSliderFlags_Logarithmic); + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##hard", &hardness_, 0.0f, 1.0f, "hard edge %.2f"); + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##strength", &paintColor_.a, 0.02f, 1.0f, "strength %.2f"); + } + if (paintTool_ == PaintTool::Fill) { + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##tol", &fillTolerance_, 0.0f, 1.0f, "how alike %.2f"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("How different a colour can be and still be filled"); + } + if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Fill || paintTool_ == PaintTool::Picker) { + ImGui::SetNextItemWidth(w); + ImGui::ColorPicker3("##picker", &paintColor_.x, ImGuiColorEditFlags_NoSidePreview | ImGuiColorEditFlags_NoSmallPreview | ImGuiColorEditFlags_PickerHueBar); + } + if (paintTool_ == PaintTool::Eraser) ImGui::TextWrapped("Paints the object's plain colour back (its colour in the Model tab)."); + if (paintTool_ == PaintTool::Picture) { + if (ImGui::Button("Paste (Ctrl+V)", ImVec2(w, 0))) pasteFromClipboard(); + if (ImGui::Button("Add a picture from a file...", ImVec2(w, 0))) dialog(6); + if (PictureLayer* l = editLayer()) { + Object* o = find(ledit_.object); + ImGui::SeparatorText(l->name.c_str()); + if (ImGui::Button("Lock it in place (Enter)", ImVec2(w, 0))) lockLayer(); + if (ImGui::Button(ledit_.fresh ? "Take it away (Esc)" : "Delete it (Del)", ImVec2(w, 0))) ledit_.fresh ? cancelFloating() : deleteLayer(*o, ledit_.index); + ImGui::SetNextItemWidth(w); + undoable([&] { return ImGui::SliderFloat("##op", &l->opacity, 0.0f, 1.0f, "see-through %.2f"); }); + if (l->sticker) { + float size[2] = {l->width, l->height}; + ImGui::SetNextItemWidth(w); + if (undoable([&] { return ImGui::DragFloat2("##wh", size, 0.002f, 0.002f, 100.0f, "%.3f m"); })) l->width = size[0], l->height = size[1]; + } else if (o && o->picture) { + if (ImGui::Button("Cover the whole picture", ImVec2(w, 0))) { + pushUndo(); + const float W = float(o->picture->w), H = float(o->picture->h); + l->corners = {glm::vec2(0, 0), glm::vec2(W, 0), glm::vec2(W, H), glm::vec2(0, H)}; + } + if (ImGui::Button("Square it up again", ImVec2(w, 0))) { + pushUndo(); + glm::vec2 c(0.0f); + for (const glm::vec2& p : l->corners) c += p * 0.25f; + const float across = (glm::distance(l->corners[0], l->corners[1]) + glm::distance(l->corners[3], l->corners[2])) * 0.5f; + const glm::vec2 half(across * 0.5f, across * 0.5f * float(l->image->h) / float(std::max(l->image->w, 1))); + l->corners = {c - half, glm::vec2(c.x + half.x, c.y - half.y), c + half, glm::vec2(c.x - half.x, c.y + half.y)}; + } + } + ImGui::Separator(); + } + ImGui::TextDisabled("New pictures go"); + int where = pasteOnSurface_ ? 0 : 1; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##where", &where, "On the model (a sticker)\0On the flat picture\0")) pasteOnSurface_ = where == 0; + ImGui::TextWrapped(pasteOnSurface_ ? "Copy a picture in any app (GIMP, a browser...) and paste it. Drag it round the model: over an edge it bends like a sticker. " + "Corners size it (Shift: stretch), the round handle turns it (Ctrl: steps). Enter locks it as a layer; click it in the Picture panel to move it again." + : "Copy a picture in any app and paste it. In the Picture panel drag its corners where you want them: it bends to fit " + "(Shift keeps its shape; drag inside to move it). Enter locks it as a layer; click it there to move it again."); + } + } + + void toolOptions() { + ImGui::Begin("Tool options", nullptr, ImGuiWindowFlags_NoCollapse); + const float w = ImGui::GetContentRegionAvail().x; + if (tab_ == Tab::Paint) { + paintOptions(w); + return ImGui::End(); + } + if (tab_ == Tab::Layout) { + layoutOptions(w); + return ImGui::End(); + } + if (tab_ == Tab::Print && printTool_ != PrintTool::None) { + printOptions(w); + return ImGui::End(); + } + const char* name = ""; + for (const ToolInfo& t : kTools) + if (t.tool == tool_) name = t.name; + ImGui::SeparatorText(name); + auto orientChoice = [&] { + ImGui::TextDisabled("Arrows point"); + int o = int(orient()); + const char* items = mode_ == Mode::Edit ? "The object's way\0The world's way\0Out of the faces\0" : "The object's way\0The world's way\0"; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##orient", &o, items)) orient() = Orient(o); + }; + auto snapChoice = [&](bool angle) { + ImGui::Checkbox("Snap", &snap_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("In steps (holding Ctrl does the opposite)"); + if (!snap_) return; + ImGui::SetNextItemWidth(w); + if (angle) ImGui::DragFloat("##snapangle", &snapAngle_, 0.5f, 1.0f, 90.0f, "every %.0f deg"); + else ImGui::DragFloat("##snapmove", &snapMove_, 0.001f, 0.001f, 10.0f, "every %.3f m"); + }; + switch (tool_) { + case Tool::Select: { + ImGui::TextDisabled("Drag to select with"); + const Icon icons[] = {Icon::Box, Icon::Brush, Icon::Lasso}; + const char* tips[] = {"A box", "A brush: paint over what you want", "A lasso: draw round it"}; + for (int i = 0; i < 3; ++i) { + if (i) ImGui::SameLine(0, 5); + ImGui::PushID(i); + if (iconButton("##shape", icons[i], int(selShape_) == i, tips[i], 34.0f)) selShape_ = SelShape(i); + ImGui::PopID(); + } + if (selShape_ == SelShape::Brush) { + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##brush", &brushRadius_, 4.0f, 200.0f, "brush %.0f px"); + } + ImGui::Checkbox("See through (X-ray)", &xray_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Select things behind others too (Alt+Z)"); + break; + } + case Tool::Move: + orientChoice(); + snapChoice(false); + break; + case Tool::Rotate: { + orientChoice(); + ImGui::TextDisabled("Turn around"); + int p = rotatePivot_ == Pivot::Origin ? 1 : 0; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##rpivot", &p, "The middle\0The origin\0")) rotatePivot_ = p ? Pivot::Origin : Pivot::Middle; + snapChoice(true); + break; + } + case Tool::Scale: { + if (mode_ == Mode::Edit) orientChoice(); + ImGui::TextDisabled("What stays put"); + int p = scalePivot_ == Pivot::Opposite ? 0 : scalePivot_ == Pivot::Middle ? 1 : scalePivot_ == Pivot::Bottom ? 2 : 3; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##spivot", &p, "The other side\0The middle\0The bottom\0The origin\0")) + scalePivot_ = p == 0 ? Pivot::Opposite : p == 1 ? Pivot::Middle : p == 2 ? Pivot::Bottom : Pivot::Origin; + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("The other side: drag a side, the opposite side stays (like stretching in a paint program).\nThe middle: both sides move.\nThe bottom: it stays standing on the ground."); + snapChoice(false); + break; + } + case Tool::Draw: + ImGui::SetNextItemWidth(w); + ImGui::DragFloat("##thick", &drawThickness_, 0.001f, 0.0f, 10.0f, "thickness %.3f m"); + ImGui::TextWrapped("Drawn on the face you start on, or on %s.", + view_ == View::Perspective || view_ == View::Top || view_ == View::Bottom ? "the ground (a side view draws upright)" : "the plane you look at"); + ImGui::TextDisabled("Drawn on an object:"); + ImGui::SetNextItemWidth(w); + ImGui::Combo("##drawon", &drawOnMode_, "A new object on it\0Added to it (raised)\0Cut into it\0"); + snapChoice(false); + if (!drawPts_.empty() && ImGui::Button("Finish (Enter)", ImVec2(w, 0))) finishShape(); + break; + case Tool::Extrude: + ImGui::SetNextItemWidth(w); + ImGui::DragFloat("##ext", &extrudeAmount_, 0.001f, -10, 10, "%.3f m"); + if (ImGui::Button("Extrude by that", ImVec2(w, 0))) extrudeNow(extrudeAmount_); + snapChoice(false); + break; + case Tool::Inset: + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##ins", &insetAmount_, 0.01f, 0.99f, "%.2f of the way in"); + if (ImGui::Button("Inset by that", ImVec2(w, 0))) insetNow(insetAmount_); + break; + case Tool::Bevel: + ImGui::SetNextItemWidth(w); + ImGui::DragFloat("##bw", &bevelWidth_, 0.001f, 0.0f, 10.0f, "width %.3f m"); + ImGui::SetNextItemWidth(w); + ImGui::SliderInt("##bs", &bevelSteps_, 1, 10, bevelSteps_ == 1 ? "1 step (a flat cut)" : "%d steps round"); + if (ImGui::Button("Round them", ImVec2(w, 0))) bevelNow(); + break; + case Tool::LoopCut: + ImGui::SetNextItemWidth(w); + ImGui::SliderInt("##cuts", &cuts_, 1, 16, cuts_ == 1 ? "1 cut" : "%d cuts"); + ImGui::TextWrapped("Cuts go across four-sided faces, all the way round."); + break; + case Tool::Knife: { + ImGui::Checkbox("Cut right through", &knifeThrough_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Through everything in the line's way, the back too (else only what you can see).\nThe line goes on past its ends."); + if (knifeThrough_) { + ImGui::TextDisabled("Then"); + ImGui::SetNextItemWidth(w); + ImGui::Combo("##after", &knifeAfter_, "Just add the edges\0Pieces of the one object\0Each piece its own object\0Delete the smaller piece\0"); + if (knifeAfter_ > 0) { + ImGui::TextDisabled("Inside, where it's cut"); + int fill = knifeFill_ ? 0 : 1; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##fill", &fill, "Filled (solid)\0Left open (hollow)\0")) knifeFill_ = fill == 0; + } + } + ImGui::TextWrapped("Click points along the cut, or drag a straight line. Ctrl: at 45 degree steps. Enter or double-click cuts, Esc lets go. " + "Each face it crosses is cut from edge to edge."); + if (knifePts_.size() >= 2 && ImGui::Button("Cut (Enter)", ImVec2(w, 0))) { + std::vector pts = knifePts_; + knifePts_.clear(); + knifeApply(active_, pts); + } + break; + } + case Tool::Hole: { + ImGui::SetNextItemWidth(w); + ImGui::Combo("##shape", &holeShape_, "Round\0Rectangle\0Triangle\0Hexagon (for nuts)\0"); + const HoleShape shape = HoleShape(holeShape_); + // Ready-made sizes for 3D prints: screws go through round holes, nuts sit in hexagons + // (a little room added, as prints come out a touch tight). + struct Fit { + const char* name; + float across, deep; // mm + }; + static const Fit screws[] = {{"M2 screw (2.4 mm)", 2.4f, 0}, {"M2.5 screw (2.9 mm)", 2.9f, 0}, {"M3 screw (3.4 mm)", 3.4f, 0}, {"M4 screw (4.5 mm)", 4.5f, 0}, + {"M5 screw (5.5 mm)", 5.5f, 0}, {"M6 screw (6.6 mm)", 6.6f, 0}, {"M8 screw (9 mm)", 9.0f, 0}}; + static const Fit nuts[] = {{"M2 nut (4 mm, 1.6 thick)", 4.0f, 1.6f}, {"M2.5 nut (5 mm, 2 thick)", 5.0f, 2.0f}, {"M3 nut (5.5 mm, 2.4 thick)", 5.5f, 2.4f}, + {"M4 nut (7 mm, 3.2 thick)", 7.0f, 3.2f}, {"M5 nut (8 mm, 4 thick)", 8.0f, 4.0f}, {"M6 nut (10 mm, 5 thick)", 10.0f, 5.0f}, + {"M8 nut (13 mm, 6.5 thick)", 13.0f, 6.5f}}; + if (shape == HoleShape::Round || shape == HoleShape::Hexagon) { + const bool nut = shape == HoleShape::Hexagon; + ImGui::SetNextItemWidth(w); + if (ImGui::BeginCombo("##fit", nut ? "Size for a nut..." : "Size for a screw...")) { + for (const Fit& f : nut ? std::span(nuts) : std::span(screws)) + if (ImGui::Selectable(f.name)) { + holeSize_ = (f.across + (nut ? holeRoom_ * 1000 : 0)) / 1000; + if (nut) holeMode_ = 1, holeDepth_ = (f.deep + 0.2f) / 1000; + else holeMode_ = 0; + } + ImGui::EndCombo(); + } + if (nut) { + float room = holeRoom_ * 1000; + ImGui::SetNextItemWidth(w); + if (ImGui::SliderFloat("##room", &room, 0.0f, 0.8f, "+ %.2f mm room")) holeRoom_ = room / 1000; + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Added to a nut's size so it pushes in (0.2 to 0.4 mm suits most printers)"); + } + } + if (shape == HoleShape::Round) { + ImGui::SetNextItemWidth(w); + ImGui::SliderInt("##sides", &holeSides_, 6, 96, "%d sides round"); + } + const char* acrossName = shape == HoleShape::Round ? "%.2f mm across" : shape == HoleShape::Rectangle ? "%.2f mm wide" : shape == HoleShape::Triangle ? "%.2f mm sides" : "%.2f mm across flats"; + float across = holeSize_ * 1000, tall = holeTall_ * 1000; + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat("##size", &across, 0.1f, 0.1f, 100000.0f, acrossName)) holeSize_ = across / 1000; + if (shape == HoleShape::Rectangle) { + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat("##tall", &tall, 0.1f, 0.1f, 100000.0f, "%.2f mm high")) holeTall_ = tall / 1000; + } + if (shape != HoleShape::Round) { + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##turn", &holeTurn_, -180.0f, 180.0f, "turned %.0f degrees"); + } + ImGui::TextDisabled("Cut"); + ImGui::SetNextItemWidth(w); + ImGui::Combo("##mode", &holeMode_, "All the way through\0A pocket\0Only the outline (a new face)\0"); + if (holeMode_ == 1) { + float deep = holeDepth_ * 1000; + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat("##depth", &deep, 0.1f, 0.1f, 100000.0f, "%.2f mm deep")) holeDepth_ = deep / 1000; + } + ImGui::TextWrapped(holeMode_ == 2 ? "Press on a face and drag out the size, or just click. Nothing's taken away: the outline is cut into the face, " + "and the new face inside it is picked, ready to Extrude, Bevel or paint." + : "Press on a face and drag out the size, or just click (the size above). It has to fit inside the face%s. " + "Round edges in Properties rounds its rim too.", + holeMode_ == 0 ? ", and the face behind" : ""); + break; + } + } + ImGui::End(); + } + + void layersPanel() { + ImGui::Begin("Layers", nullptr, ImGuiWindowFlags_NoCollapse); + // Along the top: a new folder, the picked ones into one. + if (ImGui::SmallButton("+ Folder")) { + pushUndo(); + Folder f; + f.id = scene_.nextId++, f.name = "Folder"; + scene_.folders.push_back(f); + renamingFolder_ = f.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f.name.c_str()); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("A new folder: drag things onto it"); + ImGui::SameLine(); + ImGui::BeginDisabled(selected_.empty()); + if (ImGui::SmallButton("Group (Ctrl+G)")) groupSelected(); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("The picked objects into a new folder: picked and moved together"); + if (ImGui::BeginTable("objects", 4, ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("eye", ImGuiTableColumnFlags_WidthFixed, 22); + ImGui::TableSetupColumn("lock", ImGuiTableColumnFlags_WidthFixed, 22); + ImGui::TableSetupColumn("name"); + ImGui::TableSetupColumn("go", ImGuiTableColumnFlags_WidthFixed, 22); + layerRows(0, 0); + for (Reference& ref : scene_.references) { + ImGui::PushID(int(ref.id)); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + if (ImGui::SmallButton(ref.visible ? "o" : "-")) ref.visible = !ref.visible; + ImGui::TableNextColumn(); + ImGui::TableNextColumn(); + const std::string label = "Picture: " + stdfs::path(ref.path).filename().string(); + if (ImGui::Selectable(label.c_str(), activeRef_ == ref.id)) activeRef_ = ref.id, selected_.clear(), leaveEdit(); + ImGui::TableNextColumn(); + ImGui::PopID(); + } + ImGui::EndTable(); + } + // (dropped below the list: out of its folder, to the top) + ImGui::InvisibleButton("##top", ImVec2(std::max(ImGui::GetContentRegionAvail().x, 1.0f), std::max(ImGui::GetContentRegionAvail().y, 8.0f))); + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_OBJECT")) { + const uint32_t oid = *static_cast(p->Data); + moveToFolder(selected_.count(oid) ? selected_ : std::set{oid}, 0); + } + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_FOLDER")) + if (Folder* g = findFolder(*static_cast(p->Data))) pushUndo(), g->parent = 0; + ImGui::EndDragDropTarget(); + } + ImGui::End(); + } + + // A value that's undoable: the undo point is taken when you start dragging or typing. + template + bool undoable(F widget) { + const bool changed = widget(); + if (ImGui::IsItemActivated()) pushUndo(); + return changed; + } + + void paintProperties(float w) { + Object* o = selected_.empty() ? nullptr : find(active_); + if (!o) { + ImGui::TextWrapped("Click an object to paint on it.\n\nIts surface gets laid out flat on a picture (the Picture panel), which you can paint on too, " + "or put pictures onto: copy one in any app and press Ctrl+V."); + return; + } + ImGui::TextColored(kAccentText, "%s", o->name.c_str()); + if (!o->picture) { + int size = pictureSize_ <= 512 ? 0 : pictureSize_ <= 1024 ? 1 : pictureSize_ <= 2048 ? 2 : 3; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##psize", &size, "512 x 512\0001024 x 1024\0002048 x 2048\0004096 x 4096\0")) pictureSize_ = 512 << size; + if (ImGui::Button("Start painting it", ImVec2(w, 0))) readyToPaint(o->id); + ImGui::TextDisabled("(or just paint on it)"); + return; + } + ImGui::TextDisabled("Picture %d x %d", o->picture->w, o->picture->h); + if (o->hasFinish()) ImGui::TextWrapped("It's had rounding or smoothing added since: the picture shows again when you paint (they become part of the shape)."); + ImGui::Checkbox("Show the layout lines", &showUvLines_); + // The pictures put on it, top first: click one to move it (again), the eye hides it. + ImGui::SeparatorText("Pictures on it (layers)"); + if (o->layers.empty()) ImGui::TextDisabled("None yet: paste one (Ctrl+V)"); + int move = -1, by = 0, drop = -1, merge = -1; + for (int i = int(o->layers.size()) - 1; i >= 0; --i) { + PictureLayer& l = o->layers[size_t(i)]; + const bool on = ledit_.object == o->id && ledit_.index == i; + ImGui::PushID(i); + if (ImGui::SmallButton(l.visible ? "o" : "-")) pushUndo(), l.visible = !l.visible; + if (ImGui::IsItemHovered()) ImGui::SetTooltip(l.visible ? "Hide it" : "Show it"); + ImGui::SameLine(); + if (ImGui::Selectable(std::format("{}{}", l.name, on ? " (moving)" : l.sticker ? "" : " (flat)").c_str(), on, 0, ImVec2(w - 92, 0))) + on ? lockLayer() : unlockLayer(o->id, i); + ImGui::SameLine(w - 62); + ImGui::BeginDisabled(i == int(o->layers.size()) - 1); + if (ImGui::ArrowButton("up", ImGuiDir_Up)) move = i, by = 1; + ImGui::EndDisabled(); + ImGui::SameLine(0, 2); + ImGui::BeginDisabled(i == 0); + if (ImGui::ArrowButton("down", ImGuiDir_Down)) move = i, by = -1; + ImGui::EndDisabled(); + ImGui::SameLine(0, 2); + if (ImGui::SmallButton("x")) drop = i; + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Delete it"); + if (ImGui::BeginPopupContextItem("menu")) { + if (ImGui::MenuItem("Paint it in for good")) merge = i; + if (ImGui::MenuItem("Delete")) drop = i; + ImGui::EndPopup(); + } + ImGui::PopID(); + } + if (move >= 0) pushUndo(), std::swap(o->layers[size_t(move)], o->layers[size_t(move + by)]), ledit_ = {}; + if (drop >= 0) deleteLayer(*o, drop); + if (merge >= 0) mergeLayer(*o, merge); + ImGui::Spacing(); + if (ImGui::Button("Fill it all with the colour", ImVec2(w, 0))) { + Image& img = changePicture(*o); + img = makeImage(img.w, img.h, glm::vec4(glm::vec3(paintColor_), 1.0f)); + paint_.image.reset(); + } + if (ImGui::Button("Save the picture as PNG...", ImVec2(w, 0))) dialog(7); + if (ImGui::Button("Lay it out again", ImVec2(w, 0))) { + pushUndo(); + o->uvs = autoUnwrap(o->mesh); + touched(o->id); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Lays its surface out flat on the picture again (what's painted won't fit it any more)"); + if (ImGui::Button("Remove the picture", ImVec2(w, 0))) { + pushUndo(); + o->picture.reset(), o->uvs.clear(); + touched(o->id); + } + ImGui::Spacing(); + ImGui::TextWrapped("Tip: paint last. Changing the shape's faces (extrude, knife...) lays it out again, and what's painted moves."); + } + + void renderProperties(float w) { + Look& k = scene_.look; + auto slider = [&](const char* label, float* v, float lo, float hi, const char* fmt) { + if (undoable([&] { return ImGui::SliderFloat(label, v, lo, hi, fmt); })) dirty_ = true; + }; + ImGui::SeparatorText("Sun"); + slider("Round", &k.sunTurn, 0, 360, "%.0f deg"); + slider("Up", &k.sunHeight, 2, 90, "%.0f deg"); + slider("Strength", &k.sunStrength, 0, 2, "%.2f"); + undoable([&] { return ImGui::ColorEdit3("Sun colour", &k.sunColor.x); }); + ImGui::SeparatorText("Round it"); + if (ImGui::Checkbox("Sky", &k.sky)) dirty_ = true; + if (!k.sky) undoable([&] { return ImGui::ColorEdit3("Background", &k.background.x); }); + if (ImGui::Checkbox("Ground", &k.ground)) dirty_ = true; + if (k.ground) undoable([&] { return ImGui::ColorEdit3("Ground colour", &k.groundColor.x); }); + ImGui::Checkbox("Grid", &renderGrid_); + ImGui::SeparatorText("Look"); + slider("Brightness", &k.exposure, 0.2f, 3.0f, "%.2f"); + slider("Shade in corners", &k.ao, 0, 1, "%.2f"); + slider("Soft shadows", &k.shadowSoft, 0, 4, "%.2f"); + slider("Glow", &k.bloom, 0, 0.3f, "%.3f"); + slider("Colour", &k.saturation, 0, 2, "%.2f"); + slider("Dark edges", &k.vignette, 0, 1, "%.2f"); + slider("Sharpen", &k.sharpen, 0, 1, "%.2f"); + if (ImGui::Button("Back to how it was", ImVec2(w, 0))) pushUndo(), k = Look{}; + if (ImGui::Button("Save a picture (F12)", ImVec2(w, 0))) dialog(5); + if (Object* o = selected_.size() == 1 ? find(active_) : nullptr) { + ImGui::SeparatorText(o->name.c_str()); + undoable([&] { return ImGui::ColorEdit3("Colour", &o->color.x); }); + undoable([&] { return ImGui::SliderFloat("Metal", &o->metal, 0, 1); }); + undoable([&] { return ImGui::SliderFloat("Rough", &o->rough, 0, 1); }); + ImGui::TextDisabled("(metal and rough go with it into games)"); + } + } + + // The Paint tab's picture: the selected object's, flat, to paint on and place pictures on. + void picturePanel() { + ImGui::Begin("Picture", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + Object* o = selected_.empty() ? nullptr : find(active_); + if (!o || !o->picture) { + if (tab_ == Tab::Layout) { + ImGui::TextWrapped(o ? "It has no picture yet, so nothing's laid out." : "Click an object: how its picture is laid out shows here."); + if (o && ImGui::Button("Lay it out (and start its picture)")) readyToPaint(o->id); + return ImGui::End(); + } + ImGui::TextWrapped(o ? "Paint on it (or press Start painting it in Properties) and its picture shows here." : "Click an object to paint it: its picture shows here."); + return ImGui::End(); + } + uploadPicture(*o, !paint_.active); + const int W = o->picture->w, H = o->picture->h; + const ImVec2 origin = ImGui::GetCursorScreenPos(), avail = ImGui::GetContentRegionAvail(); + if (avail.x < 16 || avail.y < 16) return ImGui::End(); + if (picZoom_ <= 0 || picFor_ != o->id) { + picZoom_ = std::min(avail.x / float(W), avail.y / float(H)) * 0.96f; + picPan_ = ImVec2((avail.x - float(W) * picZoom_) * 0.5f, (avail.y - float(H) * picZoom_) * 0.5f), picFor_ = o->id; + } + ImGui::InvisibleButton("canvas", avail, ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonMiddle | ImGuiButtonFlags_MouseButtonRight); + const bool hovered = ImGui::IsItemHovered(); + ImGuiIO& io = ImGui::GetIO(); + auto toScreen = [&](glm::vec2 p) { return ImVec2(origin.x + picPan_.x + p.x * picZoom_, origin.y + picPan_.y + p.y * picZoom_); }; + const glm::vec2 mouse((io.MousePos.x - origin.x - picPan_.x) / picZoom_, (io.MousePos.y - origin.y - picPan_.y) / picZoom_); + if (hovered && io.MouseWheel != 0) { // zoom about the mouse + const float z = std::clamp(picZoom_ * std::pow(1.2f, io.MouseWheel), 0.05f, 64.0f); + picPan_ = ImVec2(io.MousePos.x - origin.x - mouse.x * z, io.MousePos.y - origin.y - mouse.y * z), picZoom_ = z; + } + if (ImGui::IsItemActive() && (ImGui::IsMouseDown(ImGuiMouseButton_Middle) || ImGui::IsMouseDown(ImGuiMouseButton_Right))) + picPan_.x += io.MouseDelta.x, picPan_.y += io.MouseDelta.y; + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->PushClipRect(origin, ImVec2(origin.x + avail.x, origin.y + avail.y), true); + dl->AddRectFilled(origin, ImVec2(origin.x + avail.x, origin.y + avail.y), IM_COL32(24, 24, 28, 255)); + dl->AddImage(ImTextureID(r_->uiTexture(paintTex_[o->id])), toScreen({0, 0}), toScreen({float(W), float(H)})); + dl->AddRect(toScreen({0, 0}), toScreen({float(W), float(H)}), IM_COL32(0, 0, 0, 255)); + if (tab_ == Tab::Layout) { + if (o->uvsFit()) layoutCanvas(*o, dl, toScreen, mouse, hovered, W, H); + dl->PopClipRect(); + return ImGui::End(); + } + if (showUvLines_ && o->uvsFit()) + for (const auto& face : o->uvs) { + std::vector pts; + for (const glm::vec2& q : face) pts.push_back(toScreen(q * glm::vec2(W, H))); + dl->AddPolyline(pts.data(), int(pts.size()), IM_COL32(255, 255, 255, 60), ImDrawFlags_Closed, 1.0f); + } + PictureLayer* el = ledit_.object == o->id ? editLayer() : nullptr; + if (el && !el->sticker) { + ImVec2 q[4]; + for (int i = 0; i < 4; ++i) q[i] = toScreen(el->corners[size_t(i)]); + if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + ledit_.drag = -1; + for (int i = 0; i < 4; ++i) + if (std::abs(io.MousePos.x - q[i].x) < 10 && std::abs(io.MousePos.y - q[i].y) < 10) ledit_.drag = i; + if (ledit_.drag < 0 && insidePolygon(io.MousePos, {q[0], q[1], q[2], q[3]})) ledit_.drag = 4; + if (ledit_.drag >= 0) pushUndo(); + else lockLayer(); + ledit_.grab = mouse, ledit_.startCorners = el->corners; + } + if (ledit_.drag >= 0 && ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + const glm::vec2 d = mouse - ledit_.grab; + const int i = ledit_.drag; + if (i == 4) + for (int k = 0; k < 4; ++k) el->corners[size_t(k)] = ledit_.startCorners[size_t(k)] + d; + else if (io.KeyShift) { // bigger or smaller, the opposite corner staying + const glm::vec2 opp = ledit_.startCorners[size_t((i + 2) % 4)]; + const float k = glm::length(mouse - opp) / std::max(glm::length(ledit_.startCorners[size_t(i)] - opp), 1e-3f); + for (int j = 0; j < 4; ++j) el->corners[size_t(j)] = opp + (ledit_.startCorners[size_t(j)] - opp) * k; + } else + el->corners[size_t(i)] = ledit_.startCorners[size_t(i)] + d; + } + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) ledit_.drag = -1; + for (int i = 0; i < 4; ++i) q[i] = toScreen(el->corners[size_t(i)]); + dl->AddPolyline(q, 4, kAccent, ImDrawFlags_Closed, 2.0f); + for (int i = 0; i < 4; ++i) { + const bool hot = ledit_.drag == i || (std::abs(io.MousePos.x - q[i].x) < 10 && std::abs(io.MousePos.y - q[i].y) < 10); + dl->AddRectFilled(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), hot ? kHot : IM_COL32(255, 255, 255, 240)); + dl->AddRect(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), IM_COL32(20, 20, 25, 255)); + } + } else { + const bool onPicture = mouse.x >= 0 && mouse.y >= 0 && mouse.x < float(W) && mouse.y < float(H); + const glm::vec2 uv = mouse / glm::vec2(W, H); + // Which layer is on top under the mouse (0: the painting). + int under = 0; + if (const auto it = owner_.find(o->id); onPicture && it != owner_.end() && it->second.size() == size_t(W) * size_t(H)) + under = it->second[size_t(int(mouse.y)) * size_t(W) + size_t(int(mouse.x))]; + if (hovered && under && !paint_.active) ImGui::SetTooltip("%s: click to move it", o->layers[size_t(under - 1)].name.c_str()); + if (hovered && onPicture && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && (under || el)) { + if (under) unlockLayer(o->id, under - 1); // (clicking a placed picture here unlocks it) + else lockLayer(); + } else if (hovered && onPicture && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + if (paintTool_ == PaintTool::Picker || io.KeyAlt) pickColor(*o, uv); + else if (paintTool_ == PaintTool::Fill) fillAt(*o, uv); + else if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser) { + beginStroke(*o, true); + paint_.stroke.dab(*paint_.image, mouse, brushPx_ / picZoom_, hardness_); + paint_.last2D = mouse, paint_.haveLast = true; + } + } + if (paint_.active && paint_.in2D) { + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) endStroke(); + else { + const float r = brushPx_ / picZoom_, gap = glm::distance(mouse, paint_.last2D); + const int steps = std::max(1, int(std::ceil(gap / std::max(r * 0.3f, 0.5f)))); + for (int i = 1; i <= steps; ++i) paint_.stroke.dab(*paint_.image, glm::mix(paint_.last2D, mouse, float(i) / float(steps)), r, hardness_); + paint_.last2D = mouse; + uploaded_[o->id].dirty = true; + } + } + if (hovered && (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser)) dl->AddCircle(io.MousePos, brushPx_, IM_COL32(255, 255, 255, 200), 40, 1.5f); + } + dl->PopClipRect(); + ImGui::End(); + } + + void propertiesPanel() { + ImGui::Begin("Properties", nullptr, ImGuiWindowFlags_NoCollapse); + const float w = ImGui::GetContentRegionAvail().x; + if (tab_ == Tab::Paint) { + paintProperties(w); + return ImGui::End(); + } + if (tab_ == Tab::Render) { + renderProperties(w); + return ImGui::End(); + } + if (tab_ == Tab::Layout) { + layoutProperties(w); + return ImGui::End(); + } + if (tab_ == Tab::Print) { + printProperties(w); + return ImGui::End(); + } + if (Reference* ref = activeRef_ && selected_.empty() ? findRef(activeRef_) : nullptr) { + ImGui::TextWrapped("%s", ref->path.c_str()); + int axis = int(ref->axis); + if (ImGui::Combo("Facing", &axis, "Front view\0Side view\0Top view\0")) pushUndo(), ref->axis = Axis(axis); + undoable([&] { return ImGui::DragFloat2("Middle (m)", &ref->offset.x, 0.005f); }); + undoable([&] { return ImGui::DragFloat("Width (m)", &ref->width, 0.005f, -100.0f, 100.0f, "%.3f"); }); + ImGui::TextDisabled("A minus width flips it."); + undoable([&] { return ImGui::SliderFloat("See-through", &ref->opacity, 0.05f, 1.0f); }); + if (ImGui::Button("Remove picture", ImVec2(w, 0))) deleteSelected(); + ImGui::End(); + return; + } + if (Object* o = editing()) { + editProperties(*o, w); + ImGui::End(); + return; + } + if (selected_.empty()) { + ImGui::TextWrapped("Nothing selected.\n\nClick an object to select it, or add one with + in the toolbox (or the Add menu): " + "shapes and starter models, with sizes you can change. Draw shape (D) turns an outline you click into a solid."); + ImGui::End(); + return; + } + if (selected_.size() > 1) { + ImGui::Text("%zu objects selected", selected_.size()); + glm::vec3 col = find(active_) ? find(active_)->color : glm::vec3(0.8f); + if (undoable([&] { return ImGui::ColorEdit3("Colour (all)", &col.x); })) + for (uint32_t id : selected_) + if (Object* o = find(id)) o->color = col, dirty_ = true; + if (ImGui::Button("Join into one (Ctrl+J)", ImVec2(w, 0))) joinSelected(); + ImGui::End(); + return; + } + Object* o = find(*selected_.begin()); + if (!o) { + ImGui::End(); + return; + } + ImGui::BeginDisabled(o->locked); + char name[128]; + std::snprintf(name, sizeof name, "%s", o->name.c_str()); + if (undoable([&] { return ImGui::InputText("Name", name, sizeof name); }) && name[0]) o->name = name; + // A library shape: its own sizes, until its shape's edited by hand. + if (const ShapeRecipe* r = findRecipe(o->recipe)) { + ImGui::SeparatorText(std::format("{} settings", r->name).c_str()); + bool changed = false; + o->settings.resize(r->settings.size()); + for (size_t i = 0; i < r->settings.size(); ++i) { + const ShapeSetting& s = r->settings[i]; + const float speed = s.whole ? 0.1f : std::max(0.001f, (s.max - s.min) * 0.0005f); + changed |= undoable([&] { return ImGui::DragFloat(s.name, &o->settings[i], speed, s.min, s.max, s.whole ? "%.0f" : "%.3f"); }); + } + if (changed) o->mesh = makeFromRecipe(*r, o->settings), touched(o->id); + ImGui::TextDisabled("(These go once you edit the shape by hand.)"); + } + ImGui::SeparatorText("Place"); + undoable([&] { return ImGui::DragFloat3("Position (m)", &o->position.x, 0.005f, 0, 0, "%.3f"); }); + glm::vec3 euler = toEuler(o->rotation); + if (undoable([&] { return ImGui::DragFloat3("Turn (deg)", &euler.x, 0.5f, 0, 0, "%.1f"); })) o->rotation = fromEuler(euler); + if (undoable([&] { return ImGui::DragFloat3("Size", &o->scale.x, 0.005f, 0, 0, "%.3f"); })) touched(o->id); + const glm::vec3 lo = boundsMin(shownMesh(*o)) * glm::abs(o->scale), hi = boundsMax(shownMesh(*o)) * glm::abs(o->scale); + ImGui::TextDisabled("%.3f x %.3f x %.3f m", hi.x - lo.x, hi.y - lo.y, hi.z - lo.z); + ImGui::SeparatorText("Look"); + undoable([&] { return ImGui::ColorEdit3("Colour", &o->color.x); }); + undoable([&] { return ImGui::SliderFloat("Metal", &o->metal, 0, 1); }); + undoable([&] { return ImGui::SliderFloat("Rough", &o->rough, 0, 1); }); + if (ImGui::Checkbox("Smooth shading", &o->smooth)) pushUndo(), touched(o->id); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Round surfaces look round; edges sharper than the angle stay sharp"); + if (o->smooth) { + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1); + if (undoable([&] { return ImGui::SliderFloat("##angle", &o->smoothAngle, 1, 180, "creases > %.0f deg"); })) touched(o->id); + } + // Finishing touches that stay changeable: rounded edges, smoothing, a mirrored half. + ImGui::SeparatorText("Round, smooth, mirror"); + if (undoable([&] { return ImGui::DragFloat("Round edges", &o->round, 0.001f, 0.0f, 10.0f, o->round > 0 ? "%.3f m" : "off"); })) { + if (o->round > 0 && o->roundSteps > 1) o->smooth = true; + touched(o->id); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rounds off every sharp edge and corner (drag right)"); + if (o->round > 0) { + if (undoable([&] { return ImGui::SliderInt("Steps round", &o->roundSteps, 1, 8); })) touched(o->id); + if (undoable([&] { return ImGui::SliderFloat("Edges sharper than", &o->roundAngle, 1, 179, "%.0f deg"); })) touched(o->id); + } + if (undoable([&] { return ImGui::SliderInt("Smooth out", &o->smoothLevels, 0, 3, o->smoothLevels ? "%d times" : "off"); })) { + if (o->smoothLevels) o->smooth = true; + touched(o->id); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Cuts every face into smaller ones and rounds the whole shape (like clay)"); + if (o->smoothLevels && ImGui::Checkbox("Keep sharp edges sharp", &o->smoothKeepSharp)) pushUndo(), touched(o->id); + ImGui::TextDisabled("Mirror across"); + for (int a = 0; a < 3; ++a) { + ImGui::SameLine(); + bool on = o->mirror & (1 << a); + if (ImGui::Checkbox(a == 0 ? "X" : a == 1 ? "Y" : "Z", &on)) pushUndo(), o->mirror ^= 1 << a, touched(o->id); + } + if (o->hasFinish() && ImGui::Button("Make it part of the shape", ImVec2(w, 0))) applyFinish(*o); + if (o->hasFinish() && ImGui::IsItemHovered()) ImGui::SetTooltip("Bake the rounding, smoothing and mirror into the points (so you can edit them)"); + ImGui::SeparatorText("Do"); + if (ImGui::Button("Edit shape (Tab)", ImVec2(w, 0))) enterEdit(); + if (ImGui::Button("Apply turn and size", ImVec2(w, 0))) applyTransform(*o); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Bake them into the shape: turn becomes 0, size 1"); + if (ImGui::Button("Origin to middle", ImVec2((w - 6) / 2, 0))) originToMiddle(*o); + ImGui::SameLine(0, 6); + if (ImGui::Button("Origin to bottom", ImVec2(-1, 0))) originToBottom(*o); + if (ImGui::Button("Flip X", ImVec2((w - 12) / 3, 0))) mirror(*o, 0); + ImGui::SameLine(0, 6); + if (ImGui::Button("Flip Y", ImVec2((w - 12) / 3, 0))) mirror(*o, 1); + ImGui::SameLine(0, 6); + if (ImGui::Button("Flip Z", ImVec2(-1, 0))) mirror(*o, 2); + ImGui::EndDisabled(); + if (ImGui::Button(o->locked ? "Unlock" : "Lock", ImVec2((w - 6) / 2, 0))) o->locked = !o->locked; + ImGui::SameLine(0, 6); + if (ImGui::Button("Delete", ImVec2(-1, 0))) deleteSelected(); + ImGui::End(); + } + + void editProperties(Object& o, float w) { + ImGui::Text("Editing the shape of %s", o.name.c_str()); + ImGui::TextDisabled("%zu points, %zu faces", o.mesh.verts.size(), o.mesh.faces.size()); + if (o.hasFinish()) ImGui::TextDisabled("(its rounding and smoothing show again when you're done)"); + const std::set pts = selectedPoints(); + if (!pts.empty()) { + // Where the selection's middle is (in the world); typing moves it there. + const glm::vec3 c = pivotOfSelection(); + glm::vec3 at = c; + if (undoable([&] { return ImGui::DragFloat3("Middle (m)", &at.x, 0.002f, 0, 0, "%.3f"); })) { + const glm::mat4 m = objectMatrix(o), local = glm::inverse(m) * glm::translate(glm::mat4(1.0f), at - c) * m; + for (int v : pts) o.mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o.mesh.verts[size_t(v)], 1.0f)); + handMade(o); + touched(o.id); + } + } + ImGui::SeparatorText("Faces"); + ImGui::SetNextItemWidth(w * 0.45f); + ImGui::DragFloat("##ext", &extrudeAmount_, 0.001f, -10, 10, "%.3f m"); + ImGui::SameLine(); + if (ImGui::Button("Extrude", ImVec2(-1, 0))) extrudeNow(extrudeAmount_); + ImGui::SetNextItemWidth(w * 0.45f); + ImGui::SliderFloat("##ins", &insetAmount_, 0.01f, 0.99f, "%.2f"); + ImGui::SameLine(); + if (ImGui::Button("Inset", ImVec2(-1, 0))) insetNow(insetAmount_); + if (ImGui::Button("Flip", ImVec2((w - 6) / 2, 0))) flipNow(); + ImGui::SameLine(0, 6); + if (ImGui::Button("Make face", ImVec2(-1, 0))) makeFaceNow(); + if (ImGui::Button("Make into its own object (P)", ImVec2(w, 0))) separateSelected(); + ImGui::SeparatorText("Edges"); + ImGui::SetNextItemWidth(w * 0.45f); + ImGui::DragFloat("##bw", &bevelWidth_, 0.001f, 0.0f, 10.0f, "%.3f m"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(w * 0.2f); + ImGui::DragInt("##bs", &bevelSteps_, 0.1f, 1, 10); + ImGui::SameLine(); + if (ImGui::Button("Round", ImVec2(-1, 0))) bevelNow(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Round the selected edges (or the edges round the selected faces): width, steps"); + ImGui::SeparatorText("Points"); + if (ImGui::Button("Merge to middle (M)", ImVec2(w, 0))) mergeNow(); + ImGui::SetNextItemWidth(w * 0.45f); + ImGui::DragFloat("##md", &mergeDistance_, 0.0001f, 0.0f, 1.0f, "%.4f m"); + ImGui::SameLine(); + if (ImGui::Button("Merge close", ImVec2(-1, 0))) { + pushUndo(); + const int gone = mergeByDistance(o.mesh, mergeDistance_); + changedShape(o); + say(std::format("{} points merged", gone)); + } + ImGui::SeparatorText("Whole shape"); + if (ImGui::Button("Cut in four", ImVec2((w - 6) / 2, 0))) subdivideNow(false); + ImGui::SameLine(0, 6); + if (ImGui::Button("Smooth it", ImVec2(-1, 0))) subdivideNow(true); + ImGui::SeparatorText("Select"); + if (ImGui::Button("All", ImVec2((w - 12) / 3, 0))) selectAllElements(); + ImGui::SameLine(0, 6); + if (ImGui::Button("None", ImVec2((w - 12) / 3, 0))) clearElements(); + ImGui::SameLine(0, 6); + if (ImGui::Button("Invert", ImVec2(-1, 0))) invertSelection(); + if (ImGui::Button("More", ImVec2((w - 12) / 3, 0))) growSelection(); + ImGui::SameLine(0, 6); + if (ImGui::Button("Less", ImVec2((w - 12) / 3, 0))) shrinkSelection(); + ImGui::SameLine(0, 6); + if (ImGui::Button("Joined", ImVec2(-1, 0))) selectLinked(); + ImGui::Separator(); + if (ImGui::Button("Delete (Del)", ImVec2(w, 0))) deleteElements(); + if (ImGui::Button("Done (Tab)", ImVec2(w, 0))) leaveEdit(); + } + + void statusBar() { + ImGui::Begin("Status", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar); + if (now() - messageAt_ < 4.0) ImGui::TextColored(kAccentText, "%s", message_.c_str()); + else if (tab_ == Tab::Layout) { + for (const LayToolInfo& t : kLayTools) + if (t.tool == layTool_) + ImGui::TextDisabled("%s: %s · Tab: frames or corners Right drag: orbit (pan in the Picture panel) F1: all keys", t.name, t.hint); + } else if (tab_ == Tab::Print && printTool_ != PrintTool::None) { + ImGui::TextDisabled(printTool_ == PrintTool::Measure ? "Measure: click two points on the model (corners catch the mouse); a third click starts again · M: done" + : "Lay flat: click the face it should stand on: it turns so that face is down, on the bed · L: done"); + } else if (tab_ == Tab::Paint) { + for (const PaintToolInfo& t : kPaintTools) + if (t.tool == paintTool_) ImGui::TextDisabled("%s: %s · Right drag: orbit Wheel: zoom (in the Picture panel too) F1: all keys", t.name, t.hint); + } else + for (const ToolInfo& t : kTools) + if (t.tool == tool_) ImGui::TextDisabled("%s: %s · Right drag: orbit Middle drag: pan Wheel: zoom F1: all keys", t.name, t.hint); + ImGui::End(); + } + + // The view's corner: which way you're looking, buttons to look straight at a side, and + // handles to drag to orbit, pan and zoom (for a touchpad or a pen). + void viewButtons() { + const ImVec2 at(viewMax_.x - 10, viewMin_.y + 10); + ImGui::SetNextWindowPos(at, ImGuiCond_Always, ImVec2(1, 0)); + ImGui::SetNextWindowBgAlpha(0.55f); + ImGui::Begin("##views", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking); + static const char* names[] = {"Perspective", "Front", "Back", "Left", "Right", "Top", "Bottom"}; + static const char* shadings[] = {"Solid", "Rendered", "Wireframe"}; + ImGui::TextColored(kAccentText, "%s", names[int(view_)]); + ImGui::SameLine(); + ImGui::TextDisabled("%s%s", shadings[int(shading_)], xray_ ? ", X-ray" : ""); + for (int i = 1; i < 7; ++i) { + if (i != 1 && i != 4) ImGui::SameLine(0, 3); + if (ImGui::Button(names[i], ImVec2(56, 0))) setView(View(i)); + } + if (ImGui::Button("3D", ImVec2(56, 0))) leaveFlatView(); + ImGui::SameLine(0, 3); + if (ImGui::Button("Frame", ImVec2(56, 0))) frameSelected(); + ImGui::SameLine(0, 3); + if (ImGui::Button(xray_ ? "Solid" : "X-ray", ImVec2(56, 0))) xray_ = !xray_; + const ImVec2 d = ImGui::GetIO().MouseDelta; + ImGui::Button("Orbit", ImVec2(56, 0)); + if (ImGui::IsItemActive()) orbitBy(d); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag to orbit"); + ImGui::SameLine(0, 3); + ImGui::Button("Pan", ImVec2(56, 0)); + if (ImGui::IsItemActive()) panBy(d); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag to pan"); + ImGui::SameLine(0, 3); + ImGui::Button("Zoom", ImVec2(56, 0)); + if (ImGui::IsItemActive()) dist_ = std::clamp(dist_ * std::pow(1.01f, d.y), 0.01f, 5000.0f); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag up and down to zoom"); + if (tab_ != Tab::Render && ImGui::Button(shading_ == Shading::Rendered ? "Show: Solid" : "Show: Rendered", ImVec2(56 * 3 + 6, 0))) + shading_ = shading_ == Shading::Rendered ? Shading::Solid : Shading::Rendered; + ImGui::End(); + } + + void helpWindow() { + ImGui::SetNextWindowSize(ImVec2(580, 620), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Keys and mouse", &showHelp_)) return ImGui::End(); + auto rows = [](const char* title, std::initializer_list> list) { + ImGui::SeparatorText(title); + if (!ImGui::BeginTable(title, 2)) return; + ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 180); + for (auto [k, what] : list) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::TextColored(kAccentText, "%s", k); + ImGui::TableNextColumn(); + ImGui::TextWrapped("%s", what); + } + ImGui::EndTable(); + }; + rows("Looking around", {{"Right drag", "Orbit round what you look at"}, {"Middle drag / Shift+Right drag", "Pan"}, {"Wheel", "Zoom (towards the mouse)"}, + {"Orbit / Pan / Zoom buttons", "Drag them (touchpads)"}, {"F", "Frame the selection"}, {"Home", "Frame everything"}, + {"Numpad 1 / 3 / 7", "Front / side / top (Shift: the opposite)"}, {"Numpad 5", "Flat view / 3D"}, + {"Z / Shift+Z", "Solid or Rendered / Wireframe"}, {"Alt+Z", "X-ray: see and pick through"}}); + rows("Tools", {{"Q", "Select"}, {"W", "Move"}, {"E", "Rotate"}, {"R", "Scale"}, {"D", "Draw shape"}, {"X", "Extrude (shape editing)"}, {"I", "Inset (shape editing)"}, + {"B", "Round edges (shape editing)"}, {"C", "Loop cut (shape editing)"}, {"K", "Knife (shape editing)"}, {"O", "Hole"}, + {"Ctrl (held)", "Snap while dragging (or don't, if Snap is on)"}}); + rows("Tabs", {{"Ctrl+1", "Model"}, {"Ctrl+2", "Paint"}, {"Ctrl+3", "Layout"}, {"Ctrl+4", "Render"}, {"Ctrl+5", "Print"}}); + rows("Layout", {{"Q W E R", "select, move, turn, size frames"}, {"Tab", "frames or corners"}, {"Ctrl+A", "all frames"}}); + rows("Print", {{"M", "measure"}, {"L", "lay a face flat on the bed"}, {"B", "put it on the bed"}}); + rows("Painting", {{"B / E / G / I", "Brush / eraser / fill / pick a colour"}, {"Alt+click", "Pick a colour"}, {"[ / ]", "Smaller / bigger brush"}, + {"Ctrl+V", "Paste a picture onto it"}, {"Enter / Esc", "Place the picture / cancel"}, {"Shift + corner drag", "Resize keeping its shape"}, + {"Wheel, middle or right drag", "Zoom and move the Picture panel"}}); + rows("Selecting", {{"Click", "Select"}, {"Drag", "Select with a box, brush or lasso (Tool options)"}, {"Shift + click", "Objects: add to the selection. Shape: a whole loop"}, + {"Ctrl + click", "Objects: take away. Shape: add or take one"}, {"Shift+Ctrl + click an edge", "The ring across it (shape)"}, {"Alt+click / double-click", "A whole loop (shape editing)"}, {"Ctrl+A / Alt+A", "All / none"}, + {"Ctrl+I", "Invert"}, {"Ctrl + / Ctrl -", "More / less"}, {"L", "Everything joined to it"}, {"Esc", "Let go / cancel / leave shape editing"}}); + rows("Objects and shape", {{"Tab / double-click", "Edit an object's shape"}, {"1 / 2 / 3", "Points / edges / faces (shape editing)"}, {"M", "Merge points"}, + {"P", "Selected faces into their own object"}, {"Shift + Scale drag", "Scale both sides (from the middle)"}, {"Ctrl+G", "Group into a folder"}, + {"Ctrl+Shift + / -", "Add together / take the others out (last picked kept)"}, {"Del", "Delete"}, {"Ctrl+D", "Duplicate"}, {"Ctrl+J", "Join objects into one"}, + {"H / Alt+H", "Hide / show all"}}); + rows("Files", {{"Ctrl+N / O / S", "New / open / save"}, {"Ctrl+Shift+S", "Save as"}, {"Ctrl+E", "Export (GLB, glTF, FBX, OBJ, 3MF, STL, PLY)"}, {"F12", "Save a picture of the view"}, + {"Ctrl+Z / Ctrl+Y", "Undo / redo"}}); + ImGui::End(); + } + + void aboutWindow() { + ImGui::SetNextWindowSize(ImVec2(380, 0), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + if (!ImGui::Begin("About Fry3DX", &showAbout_, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End(); + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - 160) * 0.5f); + logoImage(160); + const char* title = "Fry3DX — Frying 3D Extreme"; + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(title).x) * 0.5f); + ImGui::TextColored(ImVec4(1.0f, 0.47f, 0.16f, 1.0f), "%s", title); + ImGui::TextWrapped("A 3D modeller that does what you'd expect: pick a tool, click and drag. Made by Nexoniarz."); + ImGui::End(); + } +}; + +} // namespace + +int main(int argc, char** argv) { + std::string path; + std::vector rest = {argv[0]}; + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]).ends_with(".fry") && argv[i - 1][0] != '+') path = argv[i]; // (not a console command's own) + else rest.push_back(argv[i]); + } + App app(path); + return keel::run(app, int(rest.size()), rest.data()); +} diff --git a/src/boolean.cpp b/src/boolean.cpp new file mode 100644 index 0000000..d2253ad --- /dev/null +++ b/src/boolean.cpp @@ -0,0 +1,207 @@ +// Combining shapes: one added to, taken from, or kept where it overlaps another. A BSP tree of +// each's faces cuts the other's (as csg.js does), then the pieces are joined up again: points +// shared, points that land on another piece's edge set into it (so it stays closed all round). +#include "mesh.h" + +#include +#include +#include +#include + +namespace fry { +namespace { + +constexpr float kEps = 1e-6f; + +struct Poly { + std::vector v; + glm::vec3 n{0.0f}; + float w = 0.0f; + void plane() { + n = glm::normalize(glm::cross(v[1] - v[0], v[2] - v[0])); + w = glm::dot(n, v[0]); + } + void flip() { + std::reverse(v.begin(), v.end()); + n = -n, w = -w; + } +}; + +// Splits `p` by the plane (n, w) into the four lists, as csg.js. +void split(const glm::vec3& n, float w, const Poly& p, std::vector& coFront, std::vector& coBack, std::vector& front, std::vector& back) { + enum { Co = 0, Front = 1, Back = 2, Spanning = 3 }; + int kind = 0; + std::vector types(p.v.size()); + for (size_t i = 0; i < p.v.size(); ++i) { + const float t = glm::dot(n, p.v[i]) - w; + types[i] = t < -kEps ? Back : t > kEps ? Front : Co; + kind |= types[i]; + } + switch (kind) { + case Co: (glm::dot(n, p.n) > 0 ? coFront : coBack).push_back(p); break; + case Front: front.push_back(p); break; + case Back: back.push_back(p); break; + default: { + Poly f, b; + for (size_t i = 0; i < p.v.size(); ++i) { + const size_t j = (i + 1) % p.v.size(); + const int ti = types[i], tj = types[j]; + const glm::vec3 vi = p.v[i], vj = p.v[j]; + if (ti != Back) f.v.push_back(vi); + if (ti != Front) b.v.push_back(vi); + if ((ti | tj) == Spanning) { + const float t = (w - glm::dot(n, vi)) / glm::dot(n, vj - vi); + const glm::vec3 x = vi + (vj - vi) * t; + f.v.push_back(x), b.v.push_back(x); + } + } + f.n = b.n = p.n, f.w = b.w = p.w; + if (f.v.size() >= 3) front.push_back(f); + if (b.v.size() >= 3) back.push_back(b); + } + } +} + +struct Node { + bool hasPlane = false; + glm::vec3 n{0.0f}; + float w = 0.0f; + std::unique_ptr front, back; + std::vector polys; + + void invert() { + for (Poly& p : polys) p.flip(); + n = -n, w = -w; + if (front) front->invert(); + if (back) back->invert(); + std::swap(front, back); + } + std::vector clip(const std::vector& in) const { + if (!hasPlane) return in; + std::vector f, b; + for (const Poly& p : in) split(n, w, p, f, b, f, b); + if (front) f = front->clip(f); + if (back) b = back->clip(b); + else b.clear(); + f.insert(f.end(), b.begin(), b.end()); + return f; + } + void clipTo(const Node& other) { + polys = other.clip(polys); + if (front) front->clipTo(other); + if (back) back->clipTo(other); + } + void all(std::vector& out) const { + out.insert(out.end(), polys.begin(), polys.end()); + if (front) front->all(out); + if (back) back->all(out); + } + void build(const std::vector& in) { + if (in.empty()) return; + if (!hasPlane) hasPlane = true, n = in[0].n, w = in[0].w; + std::vector f, b; + for (const Poly& p : in) split(n, w, p, polys, polys, f, b); + if (!f.empty()) { + if (!front) front = std::make_unique(); + front->build(f); + } + if (!b.empty()) { + if (!back) back = std::make_unique(); + back->build(b); + } + } +}; + +std::vector polysOf(const Mesh& m) { + std::vector out; + for (size_t f = 0; f < m.faces.size(); ++f) + for (const auto& t : triangulate(m, int(f))) { + Poly p; + p.v = {m.verts[size_t(t[0])], m.verts[size_t(t[1])], m.verts[size_t(t[2])]}; + if (glm::length(glm::cross(p.v[1] - p.v[0], p.v[2] - p.v[0])) < 1e-12f) continue; + p.plane(); + out.push_back(p); + } + return out; +} + +// The pieces as a mesh: points shared, and points that lie along another piece's edge set into it. +Mesh meshOf(const std::vector& polys) { + Mesh m; + std::map, int> at; + const float q = 1e5f; // (0.01 mm) + auto point = [&](const glm::vec3& p) { + const std::array k{std::llround(p.x * q), std::llround(p.y * q), std::llround(p.z * q)}; + auto [it, fresh] = at.try_emplace(k, int(m.verts.size())); + if (fresh) m.verts.push_back(p); + return it->second; + }; + for (const Poly& p : polys) { + std::vector f; + for (const glm::vec3& v : p.v) { + const int i = point(v); + if (f.empty() || f.back() != i) f.push_back(i); + } + while (f.size() > 1 && f.front() == f.back()) f.pop_back(); + if (f.size() >= 3) m.faces.push_back(f); + } + // T-junctions: a point in the middle of an edge it isn't a corner of. + std::vector order(m.verts.size()); + for (size_t i = 0; i < order.size(); ++i) order[i] = int(i); + std::sort(order.begin(), order.end(), [&](int a, int b) { return m.verts[size_t(a)].x < m.verts[size_t(b)].x; }); + for (auto& f : m.faces) { + std::vector out; + for (size_t i = 0; i < f.size(); ++i) { + const int a = f[i], b = f[(i + 1) % f.size()]; + out.push_back(a); + const glm::vec3 pa = m.verts[size_t(a)], pb = m.verts[size_t(b)], d = pb - pa; + const float len2 = glm::dot(d, d); + if (len2 < 1e-14f) continue; + std::vector> on; + const float lo = std::min(pa.x, pb.x) - 2e-5f, hi = std::max(pa.x, pb.x) + 2e-5f; + auto it = std::lower_bound(order.begin(), order.end(), lo, [&](int v, float x) { return m.verts[size_t(v)].x < x; }); + for (; it != order.end() && m.verts[size_t(*it)].x <= hi; ++it) { + const int v = *it; + if (v == a || v == b) continue; + const float t = glm::dot(m.verts[size_t(v)] - pa, d) / len2; + if (t <= 1e-4f || t >= 1 - 1e-4f) continue; + if (const glm::vec3 off = pa + d * t - m.verts[size_t(v)]; glm::dot(off, off) < 1e-10f) on.push_back({t, v}); + } + std::sort(on.begin(), on.end()); + for (auto [t, v] : on) out.push_back(v); + } + f = out; + } + return m; +} + +} // namespace + +bool combine(Mesh& a, const Mesh& b, Combine op) { + Node A, B; + A.build(polysOf(a)); + B.build(polysOf(b)); + if (!A.hasPlane || !B.hasPlane) return false; + std::vector bp; + switch (op) { + case Combine::Add: + A.clipTo(B), B.clipTo(A), B.invert(), B.clipTo(A), B.invert(); + B.all(bp), A.build(bp); + break; + case Combine::Subtract: + A.invert(), A.clipTo(B), B.clipTo(A), B.invert(), B.clipTo(A), B.invert(); + B.all(bp), A.build(bp), A.invert(); + break; + case Combine::Overlap: + A.invert(), B.clipTo(A), B.invert(), A.clipTo(B), B.clipTo(A); + B.all(bp), A.build(bp), A.invert(); + break; + } + std::vector out; + A.all(out); + a = meshOf(out); + mergeFlatFaces(a); + return !a.faces.empty(); +} + +} // namespace fry diff --git a/src/cut.cpp b/src/cut.cpp new file mode 100644 index 0000000..dd5565a --- /dev/null +++ b/src/cut.cpp @@ -0,0 +1,409 @@ +// The knife, pulling pieces apart, and holes. +#include "mesh.h" + +#include + +#include +#include +#include +#include + +namespace fry { +namespace { + +using Edge = std::pair; +Edge key(int a, int b) { return {std::min(a, b), std::max(a, b)}; } + +std::optional rayTriangle(const glm::vec3& o, const glm::vec3& d, const glm::vec3& a, const glm::vec3& b, const glm::vec3& c) { + const glm::vec3 e1 = b - a, e2 = c - a, p = glm::cross(d, e2); + const float det = glm::dot(e1, p); + if (std::abs(det) < 1e-14f) return std::nullopt; + const glm::vec3 s = o - a; + const float u = glm::dot(s, p) / det; + if (u < 0 || u > 1) return std::nullopt; + const glm::vec3 q = glm::cross(s, e1); + const float v = glm::dot(d, q) / det; + if (v < 0 || u + v > 1) return std::nullopt; + const float t = glm::dot(e2, q) / det; + return t > 0 ? std::optional(t) : std::nullopt; +} + +float sizeOf(const Mesh& m) { + const glm::vec3 d = boundsMax(m) - boundsMin(m); + return std::max({d.x, d.y, d.z, 1e-6f}); +} + +// Puts point `p` on every face's edge (a, b), between them. +void putOnEdge(Mesh& m, int a, int b, int p) { + for (auto& f : m.faces) + for (size_t i = 0; i < f.size(); ++i) { + const int u = f[i], v = f[(i + 1) % f.size()]; + if ((u == a && v == b) || (u == b && v == a)) { + f.insert(f.begin() + long(i) + 1, p); + break; + } + } +} + +// Two faces from one, cut between two of its corners (false if they're next to each other). +bool splitFace(Mesh& m, int face, int p, int q, int* other = nullptr) { + std::vector f = m.faces[size_t(face)]; + auto i = std::find(f.begin(), f.end(), p) - f.begin(), j = std::find(f.begin(), f.end(), q) - f.begin(); + const long n = long(f.size()); + if (i == n || j == n || i == j) return false; + if (i > j) std::swap(i, j); + if (j - i < 2 || (i == 0 && j == n - 1)) return false; + std::vector a(f.begin() + i, f.begin() + j + 1), b(f.begin() + j, f.end()); + b.insert(b.end(), f.begin(), f.begin() + i + 1); + m.faces[size_t(face)] = a; + m.faces.push_back(b); + if (other) *other = int(m.faces.size()) - 1; + return true; +} + +} // namespace + +std::set knifeCut(Mesh& m, const glm::vec3& eye, const std::vector& path, bool through) { + std::set made; + const float eps = sizeOf(m) * 1e-5f; + for (size_t s = 0; s + 1 < path.size(); ++s) { + const glm::vec3 da = path[s] - eye, db = path[s + 1] - eye; + glm::vec3 n = glm::cross(da, db); + if (glm::length(n) < 1e-12f) continue; + n = glm::normalize(n); + // Between the step's two rays: both of these >= 0 (linear along a line, so easy to clip). + auto side1 = [&](const glm::vec3& x) { return glm::dot(glm::cross(da, x - eye), n); }; + auto side2 = [&](const glm::vec3& x) { return glm::dot(glm::cross(x - eye, db), n); }; + struct Tri { + glm::vec3 a, b, c; + int face; + }; + std::vector tris; + if (!through) + for (size_t f = 0; f < m.faces.size(); ++f) + for (const auto& t : triangulate(m, int(f))) tris.push_back({m.verts[size_t(t[0])], m.verts[size_t(t[1])], m.verts[size_t(t[2])], int(f)}); + struct Crossing { + int vert = -1, a = -1, b = -1; + glm::vec3 p{0.0f}; + }; + struct Cut { + int face; + Crossing c[2]; + }; + std::vector cuts; + for (size_t f = 0; f < m.faces.size(); ++f) { + const auto& fv = m.faces[f]; + std::vector d(fv.size()); + for (size_t k = 0; k < fv.size(); ++k) d[k] = glm::dot(n, m.verts[size_t(fv[k])] - eye); + std::vector xs; + for (size_t k = 0; k < fv.size(); ++k) { + const size_t k1 = (k + 1) % fv.size(); + if (std::abs(d[k]) <= eps) xs.push_back({fv[k], -1, -1, m.verts[size_t(fv[k])]}); + else if (std::abs(d[k1]) > eps && (d[k] > 0) != (d[k1] > 0)) { + const float t = d[k] / (d[k] - d[k1]); + xs.push_back({-1, fv[k], fv[k1], glm::mix(m.verts[size_t(fv[k])], m.verts[size_t(fv[k1])], t)}); + } + } + if (xs.size() != 2) continue; // (misses it, touches a corner, lies in it, or a bent face) + // The part of the chord inside the step's wedge. + float t0 = 0, t1 = 1; + for (auto sideFn : {0, 1}) { + const float g0 = sideFn ? side2(xs[0].p) : side1(xs[0].p), g1 = sideFn ? side2(xs[1].p) : side1(xs[1].p); + if (std::abs(g1 - g0) < 1e-12f) { + if (g0 < 0) t0 = 1, t1 = 0; + continue; + } + const float at = -g0 / (g1 - g0); + if (g1 > g0) t0 = std::max(t0, at); + else t1 = std::min(t1, at); + } + if (t1 - t0 < 1e-5f) continue; + if (!through) { + const glm::vec3 c = faceCenter(m, int(f)); + if (glm::dot(faceNormal(m, int(f)), eye - c) <= 0) continue; + const glm::vec3 mid = glm::mix(xs[0].p, xs[1].p, (t0 + t1) * 0.5f), dir = mid - eye; + const float len = glm::length(dir); + bool hidden = false; + for (const Tri& t : tris) + if (t.face != int(f)) + if (auto h = rayTriangle(eye, dir / len, t.a, t.b, t.c); h && *h < len - eps * 10) { + hidden = true; + break; + } + if (hidden) continue; + } + cuts.push_back({int(f), {xs[0], xs[1]}}); + } + // New points on the edges crossed (shared with the faces either side), then the splits. + std::map onEdge; + for (Cut& c : cuts) + for (Crossing& x : c.c) + if (x.vert < 0) { + auto [it, fresh] = onEdge.try_emplace(key(x.a, x.b), int(m.verts.size())); + if (fresh) { + m.verts.push_back(x.p); + putOnEdge(m, x.a, x.b, it->second); + } + x.vert = it->second; + } + for (const Cut& c : cuts) + if (splitFace(m, c.face, c.c[0].vert, c.c[1].vert)) made.insert(key(c.c[0].vert, c.c[1].vert)); + else if (std::abs(c.c[0].vert - c.c[1].vert) > 0) { + // Already an edge of the face (it ran along one): still part of the cut. + const auto& f = m.faces[size_t(c.face)]; + for (size_t i = 0; i < f.size(); ++i) + if (key(f[i], f[(i + 1) % f.size()]) == key(c.c[0].vert, c.c[1].vert)) made.insert(key(c.c[0].vert, c.c[1].vert)); + } + } + return made; +} + +std::vector> splitAlong(Mesh& m, const std::set& cut, bool fill) { + std::map> byEdge; + for (size_t f = 0; f < m.faces.size(); ++f) { + const auto& fv = m.faces[f]; + for (size_t i = 0; i < fv.size(); ++i) byEdge[key(fv[i], fv[(i + 1) % fv.size()])].push_back(int(f)); + } + // The pieces: faces reached from each other without crossing the cut. + std::vector piece(m.faces.size(), -1); + int pieces = 0; + for (size_t start = 0; start < m.faces.size(); ++start) { + if (piece[start] >= 0) continue; + std::vector todo{int(start)}; + piece[start] = pieces; + while (!todo.empty()) { + const int f = todo.back(); + todo.pop_back(); + const auto& fv = m.faces[size_t(f)]; + for (size_t i = 0; i < fv.size(); ++i) { + const Edge e = key(fv[i], fv[(i + 1) % fv.size()]); + if (cut.count(e)) continue; + for (int g : byEdge[e]) + if (piece[size_t(g)] < 0) piece[size_t(g)] = pieces, todo.push_back(g); + } + } + ++pieces; + } + std::vector> out(static_cast(pieces)); + for (size_t f = 0; f < m.faces.size(); ++f) out[size_t(piece[f])].insert(int(f)); + if (pieces < 2) return out; + // Points shared between pieces: one copy each. + std::vector owner(m.verts.size(), -1), original; + for (size_t v = 0; v < m.verts.size(); ++v) original.push_back(int(v)); + std::map, int> copyOf; // (point, piece) → its copy + for (size_t f = 0; f < m.faces.size(); ++f) + for (int& v : m.faces[f]) { + const int p = piece[f]; + if (owner[size_t(v)] < 0) owner[size_t(v)] = p; + if (owner[size_t(v)] == p) continue; + auto [it, fresh] = copyOf.try_emplace({v, p}, int(m.verts.size())); + if (fresh) m.verts.push_back(m.verts[size_t(v)]), original.push_back(v); + v = it->second; + } + if (!fill) return out; + // Close each piece's openings along the cut. + for (int p = 0; p < pieces; ++p) { + std::map uses; + for (int f : out[size_t(p)]) { + const auto& fv = m.faces[size_t(f)]; + for (size_t i = 0; i < fv.size(); ++i) ++uses[key(fv[i], fv[(i + 1) % fv.size()])]; + } + std::multimap next; // the cap's edges: each open edge the other way round + for (int f : out[size_t(p)]) { + const auto& fv = m.faces[size_t(f)]; + for (size_t i = 0; i < fv.size(); ++i) { + const int a = fv[i], b = fv[(i + 1) % fv.size()]; + if (uses[key(a, b)] == 1 && cut.count(key(original[size_t(a)], original[size_t(b)]))) next.insert({b, a}); + } + } + while (!next.empty()) { + std::vector loop{next.begin()->first}; + int at = next.begin()->second; + next.erase(next.begin()); + while (at != loop.front()) { + loop.push_back(at); + auto it = next.find(at); + if (it == next.end()) break; + at = it->second; + next.erase(it); + } + if (at == loop.front() && loop.size() >= 3) { + m.faces.push_back(loop); + out[size_t(p)].insert(int(m.faces.size()) - 1); + } + } + } + return out; +} + +float volumeOf(const Mesh& m, const std::set& faces) { + float v = 0; + for (int f : faces) + for (const auto& t : triangulate(m, f)) v += glm::dot(m.verts[size_t(t[0])], glm::cross(m.verts[size_t(t[1])], m.verts[size_t(t[2])])) / 6.0f; + return std::abs(v); +} + +std::vector holeOutline(const Mesh& m, int face, const glm::vec3& centre, HoleShape shape, float across, float tall, int sides, float turn) { + const auto& fv = m.faces[size_t(face)]; + const glm::vec3 n = faceNormal(m, face); + glm::vec3 u(0.0f); + for (size_t i = 0; i < fv.size(); ++i) { + const glm::vec3 d = m.verts[size_t(fv[(i + 1) % fv.size()])] - m.verts[size_t(fv[i])]; + if (glm::length(d) > glm::length(u)) u = d; + } + u -= n * glm::dot(u, n); + if (glm::length(u) < 1e-9f) u = glm::cross(n, std::abs(n.z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0)); + u = glm::normalize(u); + const float a0 = glm::radians(turn); + const glm::vec3 v0 = glm::cross(n, u), ut = u * std::cos(a0) + v0 * std::sin(a0), vt = glm::cross(n, ut); + const glm::vec3 c = centre - n * glm::dot(centre - faceCenter(m, face), n); + std::vector out; + auto ring = [&](int count, float r, float start) { // (counter-clockwise about the normal) + for (int k = 0; k < count; ++k) { + const float a = start + glm::two_pi() * float(k) / float(count); + out.push_back(c + (ut * std::cos(a) + vt * std::sin(a)) * r); + } + }; + switch (shape) { + case HoleShape::Round: ring(std::max(sides, 3), across * 0.5f, 0.0f); break; + case HoleShape::Rectangle: { + const float hw = across * 0.5f, hh = (tall > 0 ? tall : across) * 0.5f; + for (glm::vec2 k : {glm::vec2(-1, -1), glm::vec2(1, -1), glm::vec2(1, 1), glm::vec2(-1, 1)}) out.push_back(c + ut * (hw * k.x) + vt * (hh * k.y)); + break; + } + case HoleShape::Triangle: ring(3, across / std::sqrt(3.0f), glm::half_pi()); break; + case HoleShape::Hexagon: ring(6, across / std::sqrt(3.0f), 0.0f); break; // (corners left and right: flats top and bottom) + } + return out; +} + +std::vector holeOutline(const Mesh& m, int face, const glm::vec3& centre, float radius, int sides) { + if (sides == 4) return holeOutline(m, face, centre, HoleShape::Rectangle, radius * 2.0f, radius * 2.0f); + return holeOutline(m, face, centre, HoleShape::Round, radius * 2.0f, 0.0f, sides); +} + +namespace { +// Sets a loop of new points (counter-clockwise about the face's normal, inside it) into a face: +// the face becomes a ring of faces round the loop. Returns the loop's points, or nothing if it +// doesn't fit (nothing changed). +std::optional> setIntoFace(Mesh& m, int face, const std::vector& loop) { + const std::vector fv = m.faces[size_t(face)]; + const glm::vec3 n = faceNormal(m, face); + const glm::vec3 u = glm::normalize(std::abs(n.z) < 0.9f ? glm::cross(glm::vec3(0, 0, 1), n) : glm::cross(glm::vec3(1, 0, 0), n)), v = glm::cross(n, u); + glm::vec3 c(0.0f); + for (const glm::vec3& p : loop) c += p; + c /= float(loop.size()); + auto flat = [&](const glm::vec3& p) { return glm::vec2(glm::dot(p - c, u), glm::dot(p - c, v)); }; + std::vector outer; + for (int i : fv) outer.push_back(flat(m.verts[size_t(i)])); + auto inside = [&](glm::vec2 p) { + bool in = false; + for (size_t i = 0, j = outer.size() - 1; i < outer.size(); j = i++) + if ((outer[i].y > p.y) != (outer[j].y > p.y) && p.x < (outer[j].x - outer[i].x) * (p.y - outer[i].y) / (outer[j].y - outer[i].y) + outer[i].x) in = !in; + return in; + }; + for (const glm::vec3& p : loop) + if (!inside(flat(p))) return std::nullopt; + // The face's corners must go round the middle in order (so each can be joined to the loop). + auto angle = [](glm::vec2 p) { return std::atan2(p.y, p.x); }; + // Angles round the middle, each further on than the last (a face that doubles back goes + // more than once round, and doesn't fit). + auto unwrap = [&](const std::vector& pts, float near) { + std::vector a; + for (const glm::vec2& p : pts) { + float x = angle(p); + if (a.empty()) { + while (x < near - glm::pi()) x += glm::two_pi(); + while (x >= near + glm::pi()) x -= glm::two_pi(); + } else { + while (x <= a.back()) x += glm::two_pi(); + while (x > a.back() + glm::two_pi()) x -= glm::two_pi(); + } + a.push_back(x); + } + return a; + }; + const std::vector ao = unwrap(outer, angle(outer[0])); + if (ao.back() - ao.front() >= glm::two_pi()) return std::nullopt; + std::vector inner; + for (const glm::vec3& p : loop) inner.push_back(flat(p)); + const std::vector ai = unwrap(inner, ao[0]); + if (ai.back() - ai.front() >= glm::two_pi()) return std::nullopt; + // Each corner of the face joins the loop point nearest its way round. + const int N = int(loop.size()), M = int(fv.size()); + auto loopAngle = [&](int k) { // (k may go past either end: whole turns) + const int q = ((k % N) + N) % N, turns = (k - q) / N; + return ai[size_t(q)] + glm::two_pi() * float(turns); + }; + std::vector at(size_t(M) + 1); + int k = -N; + for (int i = 0; i < M; ++i) { + while (std::abs(loopAngle(k + 1) - ao[size_t(i)]) <= std::abs(loopAngle(k) - ao[size_t(i)])) ++k; + at[size_t(i)] = k; + } + at[size_t(M)] = at[0] + N; + const int base = int(m.verts.size()); + for (const glm::vec3& p : loop) m.verts.push_back(p); + auto L = [&](int k) { return base + ((k % N) + N) % N; }; + bool first = true; + for (int i = 0; i < M; ++i) { + std::vector f{fv[size_t(i)], fv[size_t((i + 1) % M)]}; + for (int q = at[size_t(i) + 1]; q >= at[size_t(i)]; --q) f.push_back(L(q)); + if (first) m.faces[size_t(face)] = f, first = false; + else m.faces.push_back(f); + } + std::vector out; + for (int q = 0; q < N; ++q) out.push_back(base + q); + return out; +} +} // namespace + +int imprintOutline(Mesh& m, int face, const std::vector& outline) { + if (face < 0 || face >= int(m.faces.size()) || outline.size() < 3) return -1; + auto inner = setIntoFace(m, face, outline); + if (!inner) return -1; + m.faces.push_back(*inner); + return int(m.faces.size()) - 1; +} + +bool cutHole(Mesh& m, int face, const std::vector& outline, float depth) { + if (face < 0 || face >= int(m.faces.size()) || outline.size() < 3) return false; + const Mesh before = m; + const glm::vec3 n = faceNormal(m, face); + glm::vec3 c(0.0f); + for (const glm::vec3& p : outline) c += p; + c /= float(outline.size()); + // The face behind, found before anything changes. + int behind = -1; + float tBehind = 1e30f; + if (depth <= 0) + for (size_t f = 0; f < m.faces.size(); ++f) { + if (int(f) == face) continue; + for (const auto& t : triangulate(m, int(f))) + if (auto h = rayTriangle(c, -n, m.verts[size_t(t[0])], m.verts[size_t(t[1])], m.verts[size_t(t[2])]); h && *h > 1e-6f && *h < tBehind) tBehind = *h, behind = int(f); + } + if (depth <= 0 && behind < 0) return false; + auto front = setIntoFace(m, face, outline); + if (!front) return false; + if (depth > 0) { + m.faces.push_back(*front); + extrudeFaces(m, {int(m.faces.size()) - 1}, -depth); + return true; + } + // Through: the same outline, carried straight back onto the face behind. + const glm::vec3 nb = faceNormal(m, behind), pb = faceCenter(m, behind); + if (std::abs(glm::dot(nb, n)) < 1e-4f) return m = before, false; + std::vector back; + for (auto it = outline.rbegin(); it != outline.rend(); ++it) back.push_back(*it - n * (glm::dot(nb, pb - *it) / glm::dot(nb, -n))); + auto rear = setIntoFace(m, behind, back); + if (!rear) return m = before, false; + const int N = int(outline.size()); + for (int k = 0; k < N; ++k) { + const int k1 = (k + 1) % N; + const int bk = (*rear)[size_t(N - 1 - k)], bk1 = (*rear)[size_t(N - 1 - k1)]; + m.faces.push_back({(*front)[size_t(k)], (*front)[size_t(k1)], bk1, bk}); + } + return true; +} + +} // namespace fry diff --git a/src/export.cpp b/src/export.cpp new file mode 100644 index 0000000..c7ac7ce --- /dev/null +++ b/src/export.cpp @@ -0,0 +1,495 @@ +// Exporting for other programs: glTF / GLB (scene.cpp), OBJ, STL and PLY here. +#include "scene.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace fry { + +bool exportGltf(const std::string& path, const Scene& scene, const std::vector>& pngs, bool binary, std::string* error); + +namespace { + +// An object's triangles where it stands (Fry3DX's axes, metres), shared corners where they agree. +struct Tris { + std::vector pos, nrm; + std::vector uv; + std::vector idx; +}; +Tris trianglesOf(const Object& shown, bool painted) { + Object o = shown; + if (o.hasFinish()) o.mesh = finishedMesh(shown); + const glm::mat4 m = objectMatrix(o); + const glm::mat3 nm = glm::transpose(glm::inverse(glm::mat3(m))); + const bool mirrored = glm::determinant(glm::mat3(m)) < 0.0f; + const auto normals = cornerNormals(o.mesh, o.smooth, o.smoothAngle); + Tris t; + std::map, uint32_t> seen; + for (size_t f = 0; f < o.mesh.faces.size(); ++f) { + const auto& face = o.mesh.faces[f]; + for (const auto& tri : triangulate(o.mesh, int(f))) { + std::array c{}; + for (int k = 0; k < 3; ++k) { + const size_t corner = size_t(std::find(face.begin(), face.end(), tri[size_t(k)]) - face.begin()); + const glm::vec3 p(m * glm::vec4(o.mesh.verts[size_t(tri[size_t(k)])], 1.0f)), n = glm::normalize(nm * normals[f][corner]); + const glm::vec2 tc = painted ? o.uvs[f][corner] : glm::vec2(0.0f); + auto [it, fresh] = seen.try_emplace(std::array{p.x, p.y, p.z, n.x, n.y, n.z, tc.x, tc.y}, uint32_t(t.pos.size())); + if (fresh) t.pos.push_back(p), t.nrm.push_back(n), t.uv.push_back(tc); + c[size_t(k)] = it->second; + } + if (mirrored) std::swap(c[1], c[2]); + t.idx.insert(t.idx.end(), c.begin(), c.end()); + } + } + return t; +} + +bool painted(const Scene& s, const std::vector>& pngs, size_t i) { + return i < pngs.size() && !pngs[i].empty() && s.objects[i].uvsFit() && !s.objects[i].hasFinish(); +} + +bool writeBytes(const std::filesystem::path& p, const void* data, size_t n, std::string* error) { + std::ofstream f(p, std::ios::binary); + if (f.write(static_cast(data), std::streamsize(n))) return true; + if (error) *error = "can't write " + p.filename().string(); + return false; +} + +std::string safeName(std::string s) { + for (char& ch : s) + if (ch == ' ' || uint8_t(ch) < 0x20) ch = '_'; + return s; +} + +bool exportObj(const std::string& path, const Scene& scene, const std::vector>& pngs, std::string* error) { + const std::filesystem::path p(path); + const std::string mtlName = p.stem().string() + ".mtl"; + std::ostringstream obj, mtl; + obj.precision(7), mtl.precision(5); + obj << "# Fry3DX\nmtllib " << mtlName << "\n"; + mtl << "# Fry3DX\n"; + auto yUp = [](glm::vec3 v) { return glm::vec3(v.y, v.z, v.x); }; // (as glTF's) + size_t base = 1, any = 0; + for (size_t i = 0; i < scene.objects.size(); ++i) { + const Object& o = scene.objects[i]; + if (!o.visible || o.mesh.faces.empty()) continue; + const bool pic = painted(scene, pngs, i); + const Tris t = trianglesOf(o, pic); + const std::string name = safeName(o.name), mat = std::format("{}_{}", name, i + 1); + obj << "o " << name << "\nusemtl " << mat << "\n"; + for (const glm::vec3& v : t.pos) obj << "v " << yUp(v).x << " " << yUp(v).y << " " << yUp(v).z << "\n"; + for (const glm::vec2& v : t.uv) obj << "vt " << v.x << " " << 1.0f - v.y << "\n"; + for (const glm::vec3& v : t.nrm) obj << "vn " << yUp(v).x << " " << yUp(v).y << " " << yUp(v).z << "\n"; + for (size_t k = 0; k < t.idx.size(); k += 3) { + obj << "f"; + for (int j = 0; j < 3; ++j) obj << " " << base + t.idx[k + size_t(j)] << "/" << base + t.idx[k + size_t(j)] << "/" << base + t.idx[k + size_t(j)]; + obj << "\n"; + } + base += t.pos.size(), ++any; + const glm::vec3 c = pic ? glm::vec3(1.0f) : glm::pow(o.color, glm::vec3(2.2f)); + mtl << "newmtl " << mat << "\nKd " << c.r << " " << c.g << " " << c.b << "\nKs 0.04 0.04 0.04\nNs " << (1.0f - o.rough) * 200.0f << "\nPr " << o.rough + << "\nPm " << o.metal << "\n"; + if (pic) { + const std::string png = std::format("{}_{}.png", p.stem().string(), i + 1); + if (!writeBytes(p.parent_path() / png, pngs[i].data(), pngs[i].size(), error)) return false; + mtl << "map_Kd " << png << "\n"; + } + } + if (!any) { + if (error) *error = "nothing to export (no visible objects)"; + return false; + } + const std::string a = obj.str(), b = mtl.str(); + return writeFileSafely(path, a.data(), a.size(), error) && writeFileSafely((p.parent_path() / mtlName).string(), b.data(), b.size(), error); +} + +bool exportStl(const std::string& path, const Scene& scene, std::string* error) { + std::vector out(80, 0); + const char head[] = "Fry3DX, millimetres, Z up"; + std::memcpy(out.data(), head, sizeof head); + out.resize(84); + uint32_t count = 0; + auto put = [&](glm::vec3 v) { out.insert(out.end(), reinterpret_cast(&v), reinterpret_cast(&v) + 12); }; + for (const Object& o : scene.objects) { + if (!o.visible) continue; + const Tris t = trianglesOf(o, false); + for (size_t k = 0; k < t.idx.size(); k += 3) { + const glm::vec3 a = t.pos[t.idx[k]] * 1000.0f, b = t.pos[t.idx[k + 1]] * 1000.0f, c = t.pos[t.idx[k + 2]] * 1000.0f; + const glm::vec3 n = glm::cross(b - a, c - a); + put(glm::length(n) > 0 ? glm::normalize(n) : glm::vec3(0.0f)), put(a), put(b), put(c); + out.push_back(0), out.push_back(0); + ++count; + } + } + if (!count) { + if (error) *error = "nothing to export (no visible objects)"; + return false; + } + std::memcpy(out.data() + 80, &count, 4); + return writeFileSafely(path, out.data(), out.size(), error); +} + +bool exportPly(const std::string& path, const Scene& scene, const std::vector>& pngs, std::string* error) { + std::vector verts, faces; + size_t nv = 0, nf = 0; + auto put = [](std::vector& to, const void* p, size_t n) { to.insert(to.end(), static_cast(p), static_cast(p) + n); }; + for (size_t i = 0; i < scene.objects.size(); ++i) { + const Object& o = scene.objects[i]; + if (!o.visible) continue; + const bool pic = painted(scene, pngs, i); + const Tris t = trianglesOf(o, pic); + const uint8_t rgb[3] = {uint8_t(std::clamp(o.color.r, 0.0f, 1.0f) * 255.0f + 0.5f), uint8_t(std::clamp(o.color.g, 0.0f, 1.0f) * 255.0f + 0.5f), + uint8_t(std::clamp(o.color.b, 0.0f, 1.0f) * 255.0f + 0.5f)}; + for (size_t v = 0; v < t.pos.size(); ++v) { + const glm::vec2 st(t.uv[v].x, 1.0f - t.uv[v].y); + put(verts, &t.pos[v], 12), put(verts, &t.nrm[v], 12), put(verts, &st, 8), put(verts, rgb, 3); + } + for (size_t k = 0; k < t.idx.size(); k += 3) { + const uint8_t three = 3; + const uint32_t f[3] = {uint32_t(nv + t.idx[k]), uint32_t(nv + t.idx[k + 1]), uint32_t(nv + t.idx[k + 2])}; + put(faces, &three, 1), put(faces, f, 12); + } + nv += t.pos.size(), nf += t.idx.size() / 3; + } + if (!nf) { + if (error) *error = "nothing to export (no visible objects)"; + return false; + } + std::string head = std::format("ply\nformat binary_little_endian 1.0\ncomment Fry3DX, metres, Z up\nelement vertex {}\n" + "property float x\nproperty float y\nproperty float z\nproperty float nx\nproperty float ny\nproperty float nz\n" + "property float s\nproperty float t\nproperty uchar red\nproperty uchar green\nproperty uchar blue\n" + "element face {}\nproperty list uchar uint vertex_indices\nend_header\n", nv, nf); + std::vector out(head.begin(), head.end()); + out.insert(out.end(), verts.begin(), verts.end()), out.insert(out.end(), faces.begin(), faces.end()); + return writeFileSafely(path, out.data(), out.size(), error); +} + +// A zip with its files stored as they are (3MF is a zip). +std::vector storedZip(const std::vector>& files) { + static uint32_t table[256]; + if (!table[1]) + for (uint32_t i = 0; i < 256; ++i) { + uint32_t c = i; + for (int k = 0; k < 8; ++k) c = c & 1 ? 0xEDB88320u ^ (c >> 1) : c >> 1; + table[i] = c; + } + auto crc = [&](const std::string& d) { + uint32_t c = 0xFFFFFFFFu; + for (unsigned char ch : d) c = table[(c ^ ch) & 0xFF] ^ (c >> 8); + return c ^ 0xFFFFFFFFu; + }; + std::vector out, dir; + auto u16 = [](std::vector& v, uint16_t x) { v.push_back(uint8_t(x)), v.push_back(uint8_t(x >> 8)); }; + auto u32 = [&](std::vector& v, uint32_t x) { u16(v, uint16_t(x)), u16(v, uint16_t(x >> 16)); }; + for (const auto& [name, data] : files) { + const uint32_t at = uint32_t(out.size()), c = crc(data), n = uint32_t(data.size()); + u32(out, 0x04034b50), u16(out, 20), u16(out, 0), u16(out, 0), u16(out, 0), u16(out, 0x21), u32(out, c), u32(out, n), u32(out, n); + u16(out, uint16_t(name.size())), u16(out, 0), out.insert(out.end(), name.begin(), name.end()), out.insert(out.end(), data.begin(), data.end()); + u32(dir, 0x02014b50), u16(dir, 20), u16(dir, 20), u16(dir, 0), u16(dir, 0), u16(dir, 0), u16(dir, 0x21), u32(dir, c), u32(dir, n), u32(dir, n); + u16(dir, uint16_t(name.size())), u16(dir, 0), u16(dir, 0), u16(dir, 0), u16(dir, 0), u32(dir, 0), u32(dir, at); + dir.insert(dir.end(), name.begin(), name.end()); + } + const uint32_t dirAt = uint32_t(out.size()), dirSize = uint32_t(dir.size()); + out.insert(out.end(), dir.begin(), dir.end()); + u32(out, 0x06054b50), u16(out, 0), u16(out, 0), u16(out, uint16_t(files.size())), u16(out, uint16_t(files.size())), u32(out, dirSize), u32(out, dirAt), u16(out, 0); + return out; +} + +bool export3mf(const std::string& path, const Scene& scene, std::string* error) { + std::ostringstream model, items; + model.precision(7); + model << "\n\nFry3DX\n\n"; + auto xml = [](const std::string& s) { + std::string o; + for (char ch : s) o += ch == '&' ? "&" : ch == '<' ? "<" : ch == '>' ? ">" : ch == '"' ? """ : std::string(1, ch); + return o; + }; + int id = 0; + for (const Object& o : scene.objects) { + if (!o.visible || o.mesh.faces.empty()) continue; + const Tris t = trianglesOf(o, false); + // (corners joined again: slicers want each point once, or they see the model as not closed) + std::map, uint32_t> at; + std::vector remap(t.pos.size()); + std::ostringstream verts; + verts.precision(7); + for (size_t v = 0; v < t.pos.size(); ++v) { + const glm::vec3 p = t.pos[v] * 1000.0f; + auto [it, fresh] = at.try_emplace({p.x, p.y, p.z}, uint32_t(at.size())); + if (fresh) verts << "\n"; + remap[v] = it->second; + } + ++id; + model << "\n\n\n" << verts.str() << "\n\n"; + for (size_t k = 0; k < t.idx.size(); k += 3) { + const uint32_t a = remap[t.idx[k]], b = remap[t.idx[k + 1]], c = remap[t.idx[k + 2]]; + if (a != b && b != c && a != c) model << "\n"; + } + model << "\n\n\n"; + items << "\n"; + } + if (!id) { + if (error) *error = "nothing to export (no visible objects)"; + return false; + } + model << "\n\n" << items.str() << "\n\n"; + const auto zip = storedZip({ + {"[Content_Types].xml", "\n" + "" + "\n"}, + {"_rels/.rels", "\n" + "\n"}, + {"3D/3dmodel.model", model.str()}, + }); + return writeFileSafely(path, zip.data(), zip.size(), error); +} + +// ── FBX (binary, 7.4: what Blender, Unity, Unreal and Maya read) ── +// A tree of nodes, each a name, a list of typed values, and nodes inside it. +struct FbxNode { + std::string name; + std::vector props; + uint32_t count = 0; + std::vector kids; + + template + void raw(const T& v) { props.insert(props.end(), reinterpret_cast(&v), reinterpret_cast(&v) + sizeof v); } + FbxNode& I(int32_t v) { return props.push_back('I'), raw(v), ++count, *this; } + FbxNode& L(int64_t v) { return props.push_back('L'), raw(v), ++count, *this; } + FbxNode& D(double v) { return props.push_back('D'), raw(v), ++count, *this; } + FbxNode& C(bool v) { return props.push_back('C'), props.push_back(v ? 1 : 0), ++count, *this; } + FbxNode& S(const std::string& v, char type = 'S') { + props.push_back(uint8_t(type)), raw(uint32_t(v.size())), props.insert(props.end(), v.begin(), v.end()); + return ++count, *this; + } + FbxNode& R(const std::vector& v) { + props.push_back('R'), raw(uint32_t(v.size())), props.insert(props.end(), v.begin(), v.end()); + return ++count, *this; + } + template + FbxNode& array(char type, const std::vector& v) { + props.push_back(uint8_t(type)), raw(uint32_t(v.size())), raw(uint32_t(0)), raw(uint32_t(v.size() * sizeof(T))); + props.insert(props.end(), reinterpret_cast(v.data()), reinterpret_cast(v.data()) + v.size() * sizeof(T)); + return ++count, *this; + } + FbxNode& add(const std::string& n) { return kids.push_back({n, {}, 0, {}}), kids.back(); } + // Properties70's P: name, type, label, flags, then its values. + FbxNode& P(const std::string& n, const std::string& type, const std::string& label, const std::string& flags = "") { + return add("P").S(n).S(type).S(label).S(flags); + } + + void write(std::vector& out) const { + const size_t at = out.size(); + out.resize(at + 12); // (where it ends, how many values, their size: filled in below) + out.push_back(uint8_t(name.size())), out.insert(out.end(), name.begin(), name.end()); + out.insert(out.end(), props.begin(), props.end()); + if (!kids.empty() || props.empty()) { + for (const FbxNode& k : kids) k.write(out); + out.resize(out.size() + 13); // (the end of the list inside it) + } + const uint32_t head[3] = {uint32_t(out.size()), count, uint32_t(props.size())}; + std::memcpy(out.data() + at, head, 12); + } +}; + +bool exportFbx(const std::string& path, const Scene& scene, const std::vector>& pngs, std::string* error) { + const std::filesystem::path file(path); + auto yUp = [](glm::vec3 v) { return glm::dvec3(v.y, v.z, v.x) * 100.0; }; // (glTF's axes; FBX counts in centimetres) + std::vector top; + { + FbxNode& h = top.emplace_back(FbxNode{"FBXHeaderExtension"}); + h.add("FBXHeaderVersion").I(1003), h.add("FBXVersion").I(7400); + FbxNode& t = h.add("CreationTimeStamp"); + t.add("Version").I(1000); + for (const char* k : {"Year", "Month", "Day", "Hour", "Minute", "Second", "Millisecond"}) t.add(k).I(std::string(k) == "Year" ? 1970 : 1); + h.add("Creator").S("Fry3DX"); + } + // (the same file id and time Blender writes, which go with the footer below) + top.push_back(FbxNode{"FileId"}), top.back().R({0x28, 0xb3, 0x2a, 0xeb, 0xb6, 0x24, 0xcc, 0xc2, 0xbf, 0xc8, 0xb0, 0x2a, 0xa9, 0x2b, 0xfc, 0xf1}); + top.push_back(FbxNode{"CreationTime"}), top.back().S("1970-01-01 10:00:00:000"); + top.push_back(FbxNode{"Creator"}), top.back().S("Fry3DX"); + { + FbxNode& g = top.emplace_back(FbxNode{"GlobalSettings"}); + g.add("Version").I(1000); + FbxNode& p = g.add("Properties70"); + const std::pair axes[] = {{"UpAxis", 1}, {"UpAxisSign", 1}, {"FrontAxis", 2}, {"FrontAxisSign", 1}, {"CoordAxis", 0}, {"CoordAxisSign", 1}, + {"OriginalUpAxis", 1}, {"OriginalUpAxisSign", 1}}; + for (auto [k, v] : axes) p.P(k, "int", "Integer").I(v); + p.P("UnitScaleFactor", "double", "Number").D(1.0), p.P("OriginalUnitScaleFactor", "double", "Number").D(1.0); + } + { + FbxNode& d = top.emplace_back(FbxNode{"Documents"}); + d.add("Count").I(1); + FbxNode& doc = d.add("Document").L(1000000).S("").S("Scene"); + doc.add("Properties70"); + doc.add("RootNode").L(0); + } + top.push_back(FbxNode{"References"}); + FbxNode objects{"Objects"}, links{"Connections"}; + int64_t next = 2000000; + int models = 0, textures = 0; + for (size_t i = 0; i < scene.objects.size(); ++i) { + const Object& shown = scene.objects[i]; + if (!shown.visible || shown.mesh.faces.empty()) continue; + const bool pic = painted(scene, pngs, i); + Object o = shown; + if (o.hasFinish()) o.mesh = finishedMesh(shown); + const glm::mat4 rs = glm::mat4_cast(o.rotation) * glm::scale(glm::mat4(1.0f), o.scale); + const glm::mat3 nm = glm::transpose(glm::inverse(glm::mat3(rs))); + const bool mirrored = glm::determinant(glm::mat3(rs)) < 0.0f; + const auto normals = cornerNormals(o.mesh, o.smooth, o.smoothAngle); + std::vector verts, nrm, uv; + std::vector index, uvIndex; + for (const glm::vec3& v : o.mesh.verts) { + const glm::dvec3 p = yUp(glm::vec3(rs * glm::vec4(v, 1.0f))); + verts.insert(verts.end(), {p.x, p.y, p.z}); + } + for (size_t f = 0; f < o.mesh.faces.size(); ++f) { + const auto& fv = o.mesh.faces[f]; + for (size_t k = 0; k < fv.size(); ++k) { + const size_t c = mirrored ? fv.size() - 1 - k : k; + index.push_back(k + 1 == fv.size() ? -fv[c] - 1 : fv[c]); // (the last of a face: negative, less one) + const glm::dvec3 n = yUp(glm::normalize(nm * normals[f][c])) / 100.0; + nrm.insert(nrm.end(), {n.x, n.y, n.z}); + if (pic) { + uvIndex.push_back(int32_t(uv.size() / 2)); + uv.insert(uv.end(), {double(o.uvs[f][c].x), 1.0 - double(o.uvs[f][c].y)}); + } + } + } + const std::string name = o.name.empty() ? std::format("Object {}", i + 1) : o.name; + const int64_t geo = next++, model = next++, mat = next++; + FbxNode& g = objects.add("Geometry").L(geo).S(name + std::string("\x00\x01", 2) + "Geometry", 'S').S("Mesh"); + g.add("Properties70"); + g.add("GeometryVersion").I(124); + g.add("Vertices").array('d', verts); + g.add("PolygonVertexIndex").array('i', index); + FbxNode& ln = g.add("LayerElementNormal").I(0); + ln.add("Version").I(101), ln.add("Name").S(""), ln.add("MappingInformationType").S("ByPolygonVertex"), ln.add("ReferenceInformationType").S("Direct"); + ln.add("Normals").array('d', nrm); + if (pic) { + FbxNode& lu = g.add("LayerElementUV").I(0); + lu.add("Version").I(101), lu.add("Name").S("UVMap"), lu.add("MappingInformationType").S("ByPolygonVertex"), lu.add("ReferenceInformationType").S("IndexToDirect"); + lu.add("UV").array('d', uv), lu.add("UVIndex").array('i', uvIndex); + } + FbxNode& lm = g.add("LayerElementMaterial").I(0); + lm.add("Version").I(101), lm.add("Name").S(""), lm.add("MappingInformationType").S("AllSame"), lm.add("ReferenceInformationType").S("IndexToDirect"); + lm.add("Materials").array('i', std::vector{0}); + FbxNode& layer = g.add("Layer").I(0); + layer.add("Version").I(100); + for (const char* t : {"LayerElementNormal", "LayerElementMaterial", "LayerElementUV"}) { + if (!pic && std::string(t) == "LayerElementUV") continue; + FbxNode& e = layer.add("LayerElement"); + e.add("Type").S(t), e.add("TypedIndex").I(0); + } + + FbxNode& m = objects.add("Model").L(model).S(name + std::string("\x00\x01", 2) + "Model").S("Mesh"); + m.add("Version").I(232); + FbxNode& mp = m.add("Properties70"); + const glm::dvec3 t = yUp(o.position); + mp.P("Lcl Translation", "Lcl Translation", "", "A").D(t.x).D(t.y).D(t.z); + m.add("Shading").C(true), m.add("Culling").S("CullingOff"); + + const glm::vec3 col = pic ? glm::vec3(1.0f) : glm::pow(o.color, glm::vec3(2.2f)); + FbxNode& ma = objects.add("Material").L(mat).S(name + std::string("\x00\x01", 2) + "Material").S(""); + ma.add("Version").I(102), ma.add("ShadingModel").S("phong"), ma.add("MultiLayer").I(0); + FbxNode& pp = ma.add("Properties70"); + pp.P("DiffuseColor", "Color", "", "A").D(col.r).D(col.g).D(col.b); + pp.P("DiffuseFactor", "Number", "", "A").D(1.0); + pp.P("SpecularFactor", "Number", "", "A").D(0.5 * (1.0 - o.rough)); + pp.P("Shininess", "Number", "", "A").D(2.0 + (1.0 - o.rough) * 98.0); + pp.P("ReflectionFactor", "Number", "", "A").D(o.metal); + links.add("C").S("OO").L(model).L(0); + links.add("C").S("OO").L(geo).L(model); + links.add("C").S("OO").L(mat).L(model); + ++models; + if (pic) { // its picture: inside the file, and beside it as a PNG + const std::string png = std::format("{}_{}.png", file.stem().string(), i + 1); + if (!writeBytes(file.parent_path() / png, pngs[i].data(), pngs[i].size(), error)) return false; + const int64_t tex = next++, video = next++; + FbxNode& v = objects.add("Video").L(video).S(png + std::string("\x00\x01", 2) + "Video").S("Clip"); + v.add("Type").S("Clip"); + v.add("Properties70").P("Path", "KString", "XRefUrl", "").S(png); + v.add("UseMipMap").I(0), v.add("Filename").S(png), v.add("RelativeFilename").S(png), v.add("Content").R(pngs[i]); + FbxNode& tx = objects.add("Texture").L(tex).S(png + std::string("\x00\x01", 2) + "Texture").S(""); + tx.add("Type").S("TextureVideoClip"), tx.add("Version").I(202), tx.add("TextureName").S(png + std::string("\x00\x01", 2) + "Texture"); + tx.add("Properties70").P("UseMaterial", "bool", "", "").I(1); + tx.add("Media").S(png + std::string("\x00\x01", 2) + "Video"), tx.add("FileName").S(png), tx.add("RelativeFilename").S(png); + tx.add("ModelUVTranslation").D(0).D(0), tx.add("ModelUVScaling").D(1).D(1), tx.add("Texture_Alpha_Source").S("None"); + tx.add("Cropping").I(0).I(0).I(0).I(0); + links.add("C").S("OP").L(tex).L(mat).S("DiffuseColor"); + links.add("C").S("OO").L(video).L(tex); + ++textures; + } + } + if (!models) { + if (error) *error = "nothing to export (no visible objects)"; + return false; + } + { + FbxNode& d = top.emplace_back(FbxNode{"Definitions"}); + d.add("Version").I(100); + const std::pair kinds[] = {{"GlobalSettings", 1}, {"Model", models}, {"Geometry", models}, {"Material", models}, {"Texture", textures}, {"Video", textures}}; + int all = 0; + for (auto [k, n] : kinds) all += n; + d.add("Count").I(all); + for (auto [k, n] : kinds) + if (n) d.add("ObjectType").S(k).add("Count").I(n); + } + top.push_back(std::move(objects)); + top.push_back(std::move(links)); + + std::vector out; + const char head[] = "Kaydara FBX Binary "; + out.insert(out.end(), head, head + sizeof head); // (with its 0) + out.push_back(0x1a), out.push_back(0); + const uint32_t version = 7400; + out.insert(out.end(), reinterpret_cast(&version), reinterpret_cast(&version) + 4); + for (const FbxNode& n : top) n.write(out); + out.resize(out.size() + 13); + const uint8_t foot[] = {0xfa, 0xbc, 0xab, 0x09, 0xd0, 0xc8, 0xd4, 0x66, 0xb1, 0x76, 0xfb, 0x83, 0x1c, 0xf7, 0x26, 0x7e}; + out.insert(out.end(), foot, foot + 16), out.resize(out.size() + 4); + size_t pad = ((out.size() + 15) & ~size_t(15)) - out.size(); + out.resize(out.size() + (pad ? pad : 16)); + out.insert(out.end(), reinterpret_cast(&version), reinterpret_cast(&version) + 4); + out.resize(out.size() + 120); + const uint8_t end[] = {0xf8, 0x5a, 0x8c, 0x6a, 0xde, 0xf5, 0xd9, 0x7e, 0xec, 0xe9, 0x0c, 0xe3, 0x75, 0x8f, 0x29, 0x0b}; + out.insert(out.end(), end, end + 16); + return writeFileSafely(path, out.data(), out.size(), error); +} + +} // namespace + +const std::vector>& exportFormats() { + static const std::vector> formats = { + {".glb", "glTF binary, all in one file (games: Godot, Unity, Unreal; Blender)"}, + {".gltf", "glTF with its .bin and pictures beside it"}, + {".fbx", "FBX (Blender, Unity, Unreal, Maya, 3ds Max)"}, + {".obj", "Wavefront OBJ with an .mtl and pictures (nearly every 3D program)"}, + {".3mf", "3MF for 3D printing slicers (PrusaSlicer, Bambu Studio, Cura, Orca)"}, + {".stl", "STL for 3D printing (millimetres, shape only)"}, + {".ply", "PLY (shape, colours; scanners, MeshLab)"}, + }; + return formats; +} + +bool exportModel(const std::string& path, const Scene& scene, const std::vector>& pngs, std::string* error) { + std::string ext = std::filesystem::path(path).extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return char(std::tolower(c)); }); + if (ext == ".gltf" || ext == ".glb") return exportGltf(path, scene, pngs, ext == ".glb", error); + if (ext == ".fbx") return exportFbx(path, scene, pngs, error); + if (ext == ".obj") return exportObj(path, scene, pngs, error); + if (ext == ".3mf") return export3mf(path, scene, error); + if (ext == ".stl") return exportStl(path, scene, error); + if (ext == ".ply") return exportPly(path, scene, pngs, error); + if (error) *error = "can't export to " + ext + " files"; + return false; +} + +} // namespace fry diff --git a/src/icons.cpp b/src/icons.cpp new file mode 100644 index 0000000..2b596ab --- /dev/null +++ b/src/icons.cpp @@ -0,0 +1,185 @@ +#include "icons.h" + +#include +#include +#include +#include + +namespace fry { + +void drawIcon(ImDrawList* dl, Icon icon, ImVec2 o, float s, ImU32 c) { + auto P = [&](float x, float y) { return ImVec2(o.x + x * s, o.y + y * s); }; + const float t = std::max(1.5f, s * 0.07f); + const ImU32 dim = (c & 0x00FFFFFF) | 0x60000000; + auto line = [&](float x0, float y0, float x1, float y1, float w = 0) { dl->AddLine(P(x0, y0), P(x1, y1), c, w > 0 ? w : t); }; + auto tri = [&](float x0, float y0, float x1, float y1, float x2, float y2) { dl->AddTriangleFilled(P(x0, y0), P(x1, y1), P(x2, y2), c); }; + auto quad = [&](float x0, float y0, float x1, float y1, float x2, float y2, float x3, float y3, bool fill, ImU32 col) { + if (fill) dl->AddQuadFilled(P(x0, y0), P(x1, y1), P(x2, y2), P(x3, y3), col); + else dl->AddQuad(P(x0, y0), P(x1, y1), P(x2, y2), P(x3, y3), col, t); + }; + auto dot = [&](float x, float y, float r) { dl->AddCircleFilled(P(x, y), r * s, c); }; + switch (icon) { + case Icon::Select: { + const ImVec2 pts[] = {P(0.28f, 0.14f), P(0.28f, 0.80f), P(0.44f, 0.65f), P(0.56f, 0.88f), P(0.66f, 0.83f), P(0.55f, 0.60f), P(0.76f, 0.60f)}; + dl->AddConcavePolyFilled(pts, 7, c); + break; + } + case Icon::Move: + line(0.5f, 0.2f, 0.5f, 0.8f), line(0.2f, 0.5f, 0.8f, 0.5f); + tri(0.5f, 0.08f, 0.38f, 0.24f, 0.62f, 0.24f), tri(0.5f, 0.92f, 0.38f, 0.76f, 0.62f, 0.76f); + tri(0.08f, 0.5f, 0.24f, 0.38f, 0.24f, 0.62f), tri(0.92f, 0.5f, 0.76f, 0.38f, 0.76f, 0.62f); + break; + case Icon::Rotate: + dl->PathArcTo(P(0.5f, 0.52f), 0.30f * s, 3.14159f * 0.15f, 3.14159f * 1.75f, 24); + dl->PathStroke(c, 0, t * 1.2f); + tri(0.80f, 0.18f, 0.86f, 0.46f, 0.60f, 0.36f); + break; + case Icon::Scale: + dl->AddRect(P(0.16f, 0.16f), P(0.84f, 0.84f), dim, 0, 0, t); + dl->AddRectFilled(P(0.16f, 0.56f), P(0.44f, 0.84f), c); + line(0.44f, 0.56f, 0.76f, 0.24f); + tri(0.84f, 0.16f, 0.62f, 0.22f, 0.78f, 0.38f); + break; + case Icon::Draw: { + const ImVec2 pts[] = {P(0.18f, 0.78f), P(0.30f, 0.26f), P(0.62f, 0.18f), P(0.82f, 0.50f), P(0.60f, 0.82f)}; + dl->AddPolyline(pts, 5, c, ImDrawFlags_Closed, t); + for (const ImVec2& p : pts) dl->AddCircleFilled(p, 0.07f * s, c); + break; + } + case Icon::Extrude: + quad(0.14f, 0.70f, 0.56f, 0.70f, 0.86f, 0.52f, 0.44f, 0.52f, true, dim); + quad(0.14f, 0.42f, 0.56f, 0.42f, 0.86f, 0.24f, 0.44f, 0.24f, false, c); + line(0.50f, 0.62f, 0.50f, 0.36f), tri(0.50f, 0.10f, 0.40f, 0.30f, 0.60f, 0.30f); + break; + case Icon::Inset: + dl->AddRect(P(0.14f, 0.14f), P(0.86f, 0.86f), c, 0, 0, t); + dl->AddRectFilled(P(0.34f, 0.34f), P(0.66f, 0.66f), c); + line(0.14f, 0.14f, 0.34f, 0.34f, t * 0.6f), line(0.86f, 0.14f, 0.66f, 0.34f, t * 0.6f); + line(0.14f, 0.86f, 0.34f, 0.66f, t * 0.6f), line(0.86f, 0.86f, 0.66f, 0.66f, t * 0.6f); + break; + case Icon::Bevel: + dl->PathLineTo(P(0.16f, 0.86f)); + dl->PathLineTo(P(0.16f, 0.16f)); + dl->PathLineTo(P(0.40f, 0.16f)); + dl->PathArcTo(P(0.40f, 0.60f), 0.44f * s, -3.14159f * 0.5f, 0.0f, 16); + dl->PathLineTo(P(0.84f, 0.86f)); + dl->PathFillConvex(dim); + dl->PathLineTo(P(0.40f, 0.16f)); + dl->PathArcTo(P(0.40f, 0.60f), 0.44f * s, -3.14159f * 0.5f, 0.0f, 16); + dl->PathStroke(c, 0, t * 1.4f); + break; + case Icon::LoopCut: + dl->AddRect(P(0.12f, 0.22f), P(0.88f, 0.78f), dim, 0, 0, t); + line(0.12f, 0.50f, 0.88f, 0.50f, t * 0.6f); + line(0.50f, 0.10f, 0.50f, 0.90f, t * 1.4f); + break; + case Icon::Objects: + case Icon::Shape: + quad(0.5f, 0.12f, 0.86f, 0.30f, 0.5f, 0.48f, 0.14f, 0.30f, icon == Icon::Objects, icon == Icon::Objects ? c : dim); + quad(0.14f, 0.30f, 0.5f, 0.48f, 0.5f, 0.88f, 0.14f, 0.70f, icon == Icon::Objects, icon == Icon::Objects ? dim : dim); + quad(0.86f, 0.30f, 0.5f, 0.48f, 0.5f, 0.88f, 0.86f, 0.70f, false, c); + if (icon == Icon::Shape) + for (auto [x, y] : {std::pair{0.5f, 0.12f}, {0.86f, 0.30f}, {0.5f, 0.48f}, {0.14f, 0.30f}, {0.14f, 0.70f}, {0.5f, 0.88f}, {0.86f, 0.70f}}) dot(x, y, 0.07f); + break; + case Icon::Points: + case Icon::Edges: + case Icon::Faces: + if (icon == Icon::Faces) dl->AddRectFilled(P(0.2f, 0.2f), P(0.8f, 0.8f), c); + dl->AddRect(P(0.2f, 0.2f), P(0.8f, 0.8f), icon == Icon::Faces ? c : dim, 0, 0, t); + if (icon == Icon::Edges) line(0.2f, 0.2f, 0.8f, 0.2f, t * 2.2f), line(0.8f, 0.2f, 0.8f, 0.8f, t * 2.2f); + if (icon == Icon::Points) + for (auto [x, y] : {std::pair{0.2f, 0.2f}, {0.8f, 0.2f}, {0.8f, 0.8f}, {0.2f, 0.8f}}) dot(x, y, 0.11f); + break; + case Icon::Box: + for (int i = 0; i < 4; ++i) { // dashed + const float a = 0.16f + float(i) * 0.18f, b = a + 0.1f; + line(a, 0.18f, b, 0.18f), line(a, 0.82f, b, 0.82f), line(0.16f, a + 0.02f, 0.16f, b + 0.02f), line(0.84f, a + 0.02f, 0.84f, b + 0.02f); + } + break; + case Icon::Brush: + dl->AddCircle(P(0.5f, 0.5f), 0.32f * s, c, 24, t); + dot(0.5f, 0.5f, 0.07f); + break; + case Icon::Lasso: { + dl->PathArcTo(P(0.52f, 0.40f), 0.30f * s, 0.0f, 6.2832f, 24); + dl->PathStroke(c, ImDrawFlags_Closed, t); + line(0.32f, 0.62f, 0.26f, 0.88f); + break; + } + case Icon::Add: + line(0.5f, 0.18f, 0.5f, 0.82f, t * 1.6f), line(0.18f, 0.5f, 0.82f, 0.5f, t * 1.6f); + break; + case Icon::Knife: { + const ImVec2 blade[] = {P(0.10f, 0.90f), P(0.56f, 0.30f), P(0.70f, 0.44f)}; + dl->AddTriangleFilled(blade[0], blade[1], blade[2], c); + line(0.66f, 0.38f, 0.88f, 0.14f, t * 2.4f); + line(0.14f, 0.62f, 0.34f, 0.62f, t * 0.6f), line(0.06f, 0.74f, 0.22f, 0.74f, t * 0.6f); // (the cut) + break; + } + case Icon::Hole: + dl->AddRect(P(0.12f, 0.12f), P(0.88f, 0.88f), dim, 0, 0, t); + dl->AddCircleFilled(P(0.5f, 0.5f), 0.26f * s, (c & 0x00FFFFFF) | 0x40000000, 24); + dl->AddCircle(P(0.5f, 0.5f), 0.26f * s, c, 24, t * 1.2f); + dl->PathArcTo(P(0.5f, 0.5f), 0.18f * s, 3.14159f * 0.15f, 3.14159f * 0.85f, 12); + dl->PathStroke(c, 0, t * 0.7f); + break; + case Icon::PaintBrush: + line(0.84f, 0.14f, 0.46f, 0.52f, t * 1.6f); + dl->AddQuadFilled(P(0.40f, 0.48f), P(0.52f, 0.60f), P(0.36f, 0.80f), P(0.22f, 0.66f), c); + dl->AddTriangleFilled(P(0.22f, 0.66f), P(0.36f, 0.80f), P(0.10f, 0.90f), c); + break; + case Icon::Eraser: + quad(0.16f, 0.62f, 0.50f, 0.28f, 0.82f, 0.60f, 0.48f, 0.94f, false, c); + quad(0.16f, 0.62f, 0.33f, 0.45f, 0.65f, 0.77f, 0.48f, 0.94f, true, c); + break; + case Icon::Fill: + quad(0.16f, 0.46f, 0.46f, 0.16f, 0.76f, 0.46f, 0.46f, 0.76f, false, c); + quad(0.16f, 0.46f, 0.76f, 0.46f, 0.61f, 0.61f, 0.31f, 0.61f, true, dim); + dot(0.84f, 0.74f, 0.08f), tri(0.84f, 0.52f, 0.77f, 0.72f, 0.91f, 0.72f); + break; + case Icon::Picker: + line(0.18f, 0.82f, 0.60f, 0.40f, t * 1.6f); + line(0.50f, 0.36f, 0.64f, 0.50f, t * 1.6f); + dot(0.72f, 0.28f, 0.13f); + dot(0.16f, 0.84f, 0.05f); + break; + case Icon::Picture: + dl->AddRect(P(0.10f, 0.18f), P(0.90f, 0.82f), c, 0, 0, t); + tri(0.16f, 0.76f, 0.40f, 0.40f, 0.62f, 0.76f), tri(0.46f, 0.76f, 0.66f, 0.52f, 0.84f, 0.76f); + dot(0.70f, 0.34f, 0.07f); + break; + case Icon::Render: + dl->AddRect(P(0.10f, 0.32f), P(0.90f, 0.82f), c, 0.08f * s, 0, t); + dl->AddRectFilled(P(0.30f, 0.20f), P(0.54f, 0.33f), c); + dl->AddCircle(P(0.5f, 0.57f), 0.16f * s, c, 20, t); + dot(0.78f, 0.42f, 0.04f); + break; + case Icon::Layout: // (a frame unfolded: a cross of squares) + dl->AddRect(P(0.38f, 0.10f), P(0.62f, 0.34f), c, 0, 0, t); + dl->AddRect(P(0.14f, 0.34f), P(0.86f, 0.58f), c, 0, 0, t); + dl->AddRect(P(0.38f, 0.58f), P(0.62f, 0.90f), c, 0, 0, t); + line(0.38f, 0.34f, 0.38f, 0.58f), line(0.62f, 0.34f, 0.62f, 0.58f); + break; + case Icon::Print: // (a nozzle over what it's printed) + quad(0.34f, 0.10f, 0.66f, 0.10f, 0.66f, 0.30f, 0.34f, 0.30f, true, c); + tri(0.40f, 0.30f, 0.60f, 0.30f, 0.50f, 0.44f); + for (int i = 0; i < 3; ++i) dl->AddRectFilled(P(0.22f + 0.04f * float(i), 0.56f + 0.11f * float(i)), P(0.78f - 0.04f * float(i), 0.64f + 0.11f * float(i)), i ? dim : c); + line(0.10f, 0.90f, 0.90f, 0.90f); + break; + case Icon::Ruler: { + quad(0.08f, 0.66f, 0.66f, 0.08f, 0.92f, 0.34f, 0.34f, 0.92f, false, c); + for (int i = 1; i < 6; ++i) { + const float k = float(i) / 6.0f, x = 0.08f + 0.58f * k, y = 0.66f - 0.58f * k, l = i % 2 ? 0.09f : 0.15f; + line(x, y, x + l, y + l, t * 0.8f); + } + break; + } + case Icon::LayFlat: + quad(0.22f, 0.40f, 0.62f, 0.22f, 0.86f, 0.50f, 0.46f, 0.68f, false, c); + line(0.10f, 0.88f, 0.90f, 0.88f); + line(0.54f, 0.06f, 0.54f, 0.30f), tri(0.54f, 0.36f, 0.46f, 0.24f, 0.62f, 0.24f); + break; + } +} + +} // namespace fry diff --git a/src/icons.h b/src/icons.h new file mode 100644 index 0000000..1888e67 --- /dev/null +++ b/src/icons.h @@ -0,0 +1,11 @@ +#pragma once +// The toolbox's pictures, drawn as lines and shapes (sharp at any size, no files). +#include + +namespace fry { + +enum class Icon { Select, Move, Rotate, Scale, Draw, Extrude, Inset, Bevel, LoopCut, Objects, Shape, Points, Edges, Faces, Box, Brush, Lasso, Add, Knife, Hole, PaintBrush, Eraser, Fill, Picker, Picture, Render, Layout, Print, Ruler, LayFlat }; + +void drawIcon(ImDrawList* dl, Icon icon, ImVec2 topLeft, float size, ImU32 color); + +} // namespace fry diff --git a/src/image.cpp b/src/image.cpp new file mode 100644 index 0000000..7a85346 --- /dev/null +++ b/src/image.cpp @@ -0,0 +1,119 @@ +#include "image.h" + +#include +#include + +namespace fry { + +Image makeImage(int w, int h, const glm::vec4& c) { + Image im; + im.w = w, im.h = h; + im.px.resize(size_t(w) * size_t(h) * 4); + const uint8_t b[4] = {uint8_t(std::lround(std::clamp(c.r, 0.0f, 1.0f) * 255)), uint8_t(std::lround(std::clamp(c.g, 0.0f, 1.0f) * 255)), + uint8_t(std::lround(std::clamp(c.b, 0.0f, 1.0f) * 255)), uint8_t(std::lround(std::clamp(c.a, 0.0f, 1.0f) * 255))}; + for (size_t i = 0; i < im.px.size(); i += 4) std::copy_n(b, 4, &im.px[i]); + return im; +} + +glm::vec4 pixel(const Image& im, int x, int y) { + const uint8_t* p = im.at(std::clamp(x, 0, im.w - 1), std::clamp(y, 0, im.h - 1)); + return glm::vec4(p[0], p[1], p[2], p[3]) / 255.0f; +} + +glm::vec4 sample(const Image& im, glm::vec2 at) { + const glm::vec2 p = at - 0.5f; + const int x = int(std::floor(p.x)), y = int(std::floor(p.y)); + const float fx = p.x - float(x), fy = p.y - float(y); + return glm::mix(glm::mix(pixel(im, x, y), pixel(im, x + 1, y), fx), glm::mix(pixel(im, x, y + 1), pixel(im, x + 1, y + 1), fx), fy); +} + +void Stroke::begin(const Image& im, const glm::vec4& c) { + base = im, color = c; + cover.assign(size_t(im.w) * size_t(im.h), 0.0f); +} + +void Stroke::put(Image& im, int x, int y, float amount) { + if (x < 0 || y < 0 || x >= im.w || y >= im.h) return; + float& c = cover[size_t(y) * size_t(im.w) + size_t(x)]; + if (amount <= c) return; + c = amount; + const float a = std::clamp(c * color.a, 0.0f, 1.0f); + const uint8_t* b = base.at(x, y); + uint8_t* p = im.at(x, y); + for (int k = 0; k < 3; ++k) p[k] = uint8_t(std::lround(float(b[k]) + (color[k] * 255.0f - float(b[k])) * a)); + p[3] = uint8_t(std::lround(float(b[3]) + (255.0f - float(b[3])) * a)); +} + +void Stroke::dab(Image& im, glm::vec2 c, float r, float hardness) { + r = std::max(r, 0.5f); + const int x0 = int(std::floor(c.x - r)), x1 = int(std::ceil(c.x + r)), y0 = int(std::floor(c.y - r)), y1 = int(std::ceil(c.y + r)); + const float inner = std::clamp(hardness, 0.0f, 0.999f) * r; + for (int y = std::max(y0, 0); y <= std::min(y1, im.h - 1); ++y) + for (int x = std::max(x0, 0); x <= std::min(x1, im.w - 1); ++x) { + const float d = std::hypot(float(x) + 0.5f - c.x, float(y) + 0.5f - c.y); + if (d >= r) continue; + const float t = d <= inner ? 1.0f : 1.0f - (d - inner) / (r - inner); + put(im, x, y, t * t * (3 - 2 * t)); + } +} + +void floodFill(Image& im, glm::ivec2 at, const glm::vec4& color, float tolerance) { + if (at.x < 0 || at.y < 0 || at.x >= im.w || at.y >= im.h) return; + const glm::vec4 from = pixel(im, at.x, at.y); + const Image before = im; + std::vector done(size_t(im.w) * size_t(im.h), 0); + std::vector todo{at}; + const uint8_t c[4] = {uint8_t(std::lround(color.r * 255)), uint8_t(std::lround(color.g * 255)), uint8_t(std::lround(color.b * 255)), 255}; + while (!todo.empty()) { + const glm::ivec2 p = todo.back(); + todo.pop_back(); + if (p.x < 0 || p.y < 0 || p.x >= im.w || p.y >= im.h) continue; + char& d = done[size_t(p.y) * size_t(im.w) + size_t(p.x)]; + if (d) continue; + d = 1; + const glm::vec4 q = pixel(before, p.x, p.y); + if (std::max({std::abs(q.r - from.r), std::abs(q.g - from.g), std::abs(q.b - from.b), std::abs(q.a - from.a)}) > tolerance) continue; + std::copy_n(c, 4, im.at(p.x, p.y)); + todo.insert(todo.end(), {{p.x + 1, p.y}, {p.x - 1, p.y}, {p.x, p.y + 1}, {p.x, p.y - 1}}); + } +} + +glm::mat3 squareToQuad(const std::array& q) { + // Heckbert's: (0,0)→q0, (1,0)→q1, (1,1)→q2, (0,1)→q3. + const glm::vec2 d1 = q[1] - q[2], d2 = q[3] - q[2], d3 = q[0] - q[1] + q[2] - q[3]; + float g = 0, h = 0; + if (std::abs(d3.x) > 1e-9f || std::abs(d3.y) > 1e-9f) { + const float den = d1.x * d2.y - d2.x * d1.y; + if (std::abs(den) > 1e-12f) g = (d3.x * d2.y - d2.x * d3.y) / den, h = (d1.x * d3.y - d3.x * d1.y) / den; + } + const float a = q[1].x - q[0].x + g * q[1].x, b = q[3].x - q[0].x + h * q[3].x, c = q[0].x; + const float d = q[1].y - q[0].y + g * q[1].y, e = q[3].y - q[0].y + h * q[3].y, f = q[0].y; + return glm::mat3(glm::vec3(a, d, g), glm::vec3(b, e, h), glm::vec3(c, f, 1.0f)); // (columns) +} + +glm::vec2 mapSquare(const glm::mat3& m, glm::vec2 uv) { + const glm::vec3 p = m * glm::vec3(uv, 1.0f); + return glm::vec2(p) / p.z; +} + +void stampQuad(Image& dst, const Image& src, const std::array& q, float opacity, std::vector* owner, uint8_t id) { + if (src.empty()) return; + const glm::mat3 inv = glm::inverse(squareToQuad(q)); + glm::vec2 lo(1e30f), hi(-1e30f); + for (const glm::vec2& p : q) lo = glm::min(lo, p), hi = glm::max(hi, p); + for (int y = std::max(int(lo.y), 0); y <= std::min(int(hi.y) + 1, dst.h - 1); ++y) + for (int x = std::max(int(lo.x), 0); x <= std::min(int(hi.x) + 1, dst.w - 1); ++x) { + const glm::vec3 h = inv * glm::vec3(float(x) + 0.5f, float(y) + 0.5f, 1.0f); + if (std::abs(h.z) < 1e-12f) continue; + const glm::vec2 uv = glm::vec2(h) / h.z; + if (uv.x < 0 || uv.y < 0 || uv.x > 1 || uv.y > 1) continue; + const glm::vec4 s = sample(src, uv * glm::vec2(src.w, src.h)); + const float a = s.a * opacity; + if (owner && a > 0.05f) (*owner)[size_t(y) * size_t(dst.w) + size_t(x)] = id; + uint8_t* p = dst.at(x, y); + for (int k = 0; k < 3; ++k) p[k] = uint8_t(std::lround(float(p[k]) + (s[k] * 255.0f - float(p[k])) * a)); + p[3] = uint8_t(std::lround(float(p[3]) + (255.0f - float(p[3])) * a)); + } +} + +} // namespace fry diff --git a/src/image.h b/src/image.h new file mode 100644 index 0000000..a3b9f2d --- /dev/null +++ b/src/image.h @@ -0,0 +1,49 @@ +#pragma once +// Pictures to paint: RGBA, 8 bits a channel, as seen (sRGB), rows from the top. +#include + +#include +#include +#include + +namespace fry { + +struct Image { + int w = 0, h = 0; + std::vector px; + bool empty() const { return w <= 0 || h <= 0; } + uint8_t* at(int x, int y) { return &px[(size_t(y) * size_t(w) + size_t(x)) * 4]; } + const uint8_t* at(int x, int y) const { return &px[(size_t(y) * size_t(w) + size_t(x)) * 4]; } +}; + +Image makeImage(int w, int h, const glm::vec4& color); // colour 0..1 +glm::vec4 pixel(const Image& im, int x, int y); // 0..1 +// Smoothly between pixels (pixel centres at .5), edges held. +glm::vec4 sample(const Image& im, glm::vec2 at); + +// A brush stroke: paints over the picture as it was when the stroke began, so going over the same +// place again in one stroke doesn't build up (as in GIMP). +struct Stroke { + Image base; + std::vector cover; // how much of the colour each pixel has had, this stroke + glm::vec4 color{1.0f}; // a: how strong the stroke is + void begin(const Image& im, const glm::vec4& c); + // Paints `amount` (0..1) of the colour at a pixel. + void put(Image& im, int x, int y, float amount); + // A round dab, `radius` pixels, soft edge (hardness 1: none). + void dab(Image& im, glm::vec2 centre, float radius, float hardness); +}; + +// Fills the patch round a pixel whose colour is within `tolerance` (0..1) of it. +void floodFill(Image& im, glm::ivec2 at, const glm::vec4& color, float tolerance); + +// The perspective map from the unit square (0,0 top-left .. 1,1) to a four-cornered shape +// (corners top-left, top-right, bottom-right, bottom-left): (u, v, 1) → (x·w, y·w, w). +glm::mat3 squareToQuad(const std::array& q); +glm::vec2 mapSquare(const glm::mat3& m, glm::vec2 uv); +// `src` painted into `dst` with its corners at these four pixel points: moved, stretched, turned +// or bent in perspective, as the corners say. +// owner: marks the pixels it shows on with `id` (which picture is on top where). +void stampQuad(Image& dst, const Image& src, const std::array& corners, float opacity = 1.0f, std::vector* owner = nullptr, uint8_t id = 0); + +} // namespace fry diff --git a/src/mesh.cpp b/src/mesh.cpp new file mode 100644 index 0000000..c79cfdc --- /dev/null +++ b/src/mesh.cpp @@ -0,0 +1,431 @@ +#include "mesh.h" + +#include +#include +#include +#include + +namespace fry { + +namespace { +constexpr float kPi = 3.14159265358979f; + +// Removes repeated neighbours from a face (and the last if it's the first again). +void tidyFace(std::vector& f) { + f.erase(std::unique(f.begin(), f.end()), f.end()); + while (f.size() > 1 && f.front() == f.back()) f.pop_back(); +} + +// Renumbers points through `to` (old → new, -1: gone) and tidies the faces, dropping any left +// with fewer than three corners. +void remapFaces(Mesh& m, const std::vector& to) { + std::vector> faces; + for (auto f : m.faces) { + bool gone = false; + for (int& v : f) gone |= (v = to[size_t(v)]) < 0; + tidyFace(f); + if (!gone && f.size() >= 3) faces.push_back(std::move(f)); + } + m.faces = std::move(faces); +} +} // namespace + +// ── Shapes ── + +Mesh makeCube(float size) { + const float h = size * 0.5f; + Mesh m; + for (int i = 0; i < 8; ++i) m.verts.emplace_back(i & 1 ? h : -h, i & 2 ? h : -h, i & 4 ? h : -h); + m.faces = {{4, 5, 7, 6}, {0, 2, 3, 1}, {1, 3, 7, 5}, {0, 4, 6, 2}, {2, 6, 7, 3}, {0, 1, 5, 4}}; + return m; +} + +Mesh makePlane(float size) { + const float h = size * 0.5f; + return {{{-h, -h, 0}, {h, -h, 0}, {h, h, 0}, {-h, h, 0}}, {{0, 1, 2, 3}}}; +} + +Mesh makeCylinder(int sides, float radius, float height) { + sides = std::max(sides, 3); + Mesh m; + for (int z = 0; z < 2; ++z) + for (int i = 0; i < sides; ++i) { + const float a = 2 * kPi * float(i) / float(sides); + m.verts.emplace_back(radius * std::cos(a), radius * std::sin(a), (z ? 0.5f : -0.5f) * height); + } + std::vector top, bottom; + for (int i = 0; i < sides; ++i) { + const int j = (i + 1) % sides; + m.faces.push_back({i, j, sides + j, sides + i}); + top.push_back(sides + i), bottom.push_back(sides - 1 - i); + } + m.faces.push_back(top), m.faces.push_back(bottom); + return m; +} + +Mesh makeCone(int sides, float radius, float height) { + sides = std::max(sides, 3); + Mesh m; + for (int i = 0; i < sides; ++i) { + const float a = 2 * kPi * float(i) / float(sides); + m.verts.emplace_back(radius * std::cos(a), radius * std::sin(a), -0.5f * height); + } + m.verts.emplace_back(0, 0, 0.5f * height); + std::vector bottom; + for (int i = 0; i < sides; ++i) m.faces.push_back({i, (i + 1) % sides, sides}), bottom.push_back(sides - 1 - i); + m.faces.push_back(bottom); + return m; +} + +Mesh makeSphere(int segments, int rings, float radius) { + segments = std::max(segments, 3), rings = std::max(rings, 2); + Mesh m; + m.verts.emplace_back(0, 0, radius); // the top + for (int k = 1; k < rings; ++k) { + const float t = kPi * float(k) / float(rings); + for (int i = 0; i < segments; ++i) { + const float a = 2 * kPi * float(i) / float(segments); + m.verts.emplace_back(radius * std::sin(t) * std::cos(a), radius * std::sin(t) * std::sin(a), radius * std::cos(t)); + } + } + const int bottom = int(m.verts.size()); + m.verts.emplace_back(0, 0, -radius); + auto at = [&](int k, int i) { return 1 + (k - 1) * segments + (i % segments); }; + for (int i = 0; i < segments; ++i) { + m.faces.push_back({0, at(1, i), at(1, i + 1)}); + for (int k = 1; k + 1 < rings; ++k) m.faces.push_back({at(k, i), at(k + 1, i), at(k + 1, i + 1), at(k, i + 1)}); + m.faces.push_back({at(rings - 1, i), bottom, at(rings - 1, i + 1)}); + } + return m; +} + +Mesh makeTorus(int segments, int sides, float radius, float tube) { + segments = std::max(segments, 3), sides = std::max(sides, 3); + Mesh m; + for (int u = 0; u < segments; ++u) + for (int v = 0; v < sides; ++v) { + const float a = 2 * kPi * float(u) / float(segments), b = 2 * kPi * float(v) / float(sides); + const float r = radius + tube * std::cos(b); + m.verts.emplace_back(r * std::cos(a), r * std::sin(a), tube * std::sin(b)); + } + auto at = [&](int u, int v) { return (u % segments) * sides + (v % sides); }; + for (int u = 0; u < segments; ++u) + for (int v = 0; v < sides; ++v) m.faces.push_back({at(u, v), at(u + 1, v), at(u + 1, v + 1), at(u, v + 1)}); + return m; +} + +Mesh extrudeOutline(const std::vector& outline, const glm::vec3& planeNormal, float thickness) { + std::vector pts; + for (const glm::vec3& p : outline) + if (pts.empty() || glm::distance(pts.back(), p) > 1e-5f) pts.push_back(p); + while (pts.size() > 1 && glm::distance(pts.front(), pts.back()) < 1e-5f) pts.pop_back(); + Mesh m; + if (pts.size() < 3) return m; + const glm::vec3 n = glm::normalize(planeNormal); + Mesh flat{pts, {std::vector(pts.size())}}; + std::iota(flat.faces[0].begin(), flat.faces[0].end(), 0); + if (glm::dot(faceNormal(flat, 0), n) < 0.0f) std::reverse(pts.begin(), pts.end()); // counter-clockwise round n + const int c = int(pts.size()); + const glm::vec3 half = n * (thickness * 0.5f); + for (const glm::vec3& p : pts) m.verts.push_back(p + half); + for (const glm::vec3& p : pts) m.verts.push_back(p - half); + std::vector front(static_cast(c)), back(static_cast(c)); + for (int i = 0; i < c; ++i) front[size_t(i)] = i, back[size_t(i)] = c + (c - 1 - i); + m.faces.push_back(front); + if (thickness > 0.0f) { + m.faces.push_back(back); + for (int i = 0; i < c; ++i) { + const int j = (i + 1) % c; + m.faces.push_back({c + i, c + j, j, i}); + } + } else { + m.verts.resize(size_t(c)); // (no thickness: just the flat shape) + } + return m; +} + +// ── Looking at it ── + +glm::vec3 faceNormal(const Mesh& m, int face) { + const auto& f = m.faces[size_t(face)]; + glm::vec3 n(0.0f); + for (size_t i = 0; i < f.size(); ++i) { + const glm::vec3 &a = m.verts[size_t(f[i])], &b = m.verts[size_t(f[(i + 1) % f.size()])]; + n += glm::vec3((a.y - b.y) * (a.z + b.z), (a.z - b.z) * (a.x + b.x), (a.x - b.x) * (a.y + b.y)); + } + const float l = glm::length(n); + return l > 1e-12f ? n / l : glm::vec3(0, 0, 1); +} + +glm::vec3 faceCenter(const Mesh& m, int face) { + glm::vec3 c(0.0f); + for (int v : m.faces[size_t(face)]) c += m.verts[size_t(v)]; + return c / float(std::max(m.faces[size_t(face)].size(), 1)); +} + +float faceArea(const Mesh& m, int face) { + const auto& f = m.faces[size_t(face)]; + glm::vec3 n(0.0f); + for (size_t i = 0; i < f.size(); ++i) n += glm::cross(m.verts[size_t(f[i])], m.verts[size_t(f[(i + 1) % f.size()])]); + return glm::length(n) * 0.5f; +} + +std::vector> triangulate(const Mesh& m, int face) { + const auto& f = m.faces[size_t(face)]; + std::vector> out; + if (f.size() < 3) return out; + if (f.size() == 3) return {{f[0], f[1], f[2]}}; + // Onto the face's plane (u × v = its normal, so counter-clockwise stays so), then cut off + // ears: corners that bulge out with nothing of the face inside them. + const glm::vec3 n = faceNormal(m, face); + const glm::vec3 u = glm::normalize(std::abs(n.z) < 0.9f ? glm::cross(glm::vec3(0, 0, 1), n) : glm::cross(glm::vec3(1, 0, 0), n)); + const glm::vec3 v = glm::cross(n, u); + std::vector p; + for (int i : f) p.emplace_back(glm::dot(m.verts[size_t(i)], u), glm::dot(m.verts[size_t(i)], v)); + auto cross2 = [](glm::vec2 a, glm::vec2 b, glm::vec2 c) { return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); }; + std::vector left(f.size()); + std::iota(left.begin(), left.end(), 0); + while (left.size() > 3) { + bool cut = false; + for (size_t i = 0; i < left.size() && !cut; ++i) { + const int a = left[(i + left.size() - 1) % left.size()], b = left[i], c = left[(i + 1) % left.size()]; + if (cross2(p[size_t(a)], p[size_t(b)], p[size_t(c)]) <= 1e-12f) continue; // dents in, or flat + bool inside = false; + for (int o : left) { + if (o == a || o == b || o == c) continue; + const glm::vec2 q = p[size_t(o)]; + if (cross2(p[size_t(a)], p[size_t(b)], q) >= 0 && cross2(p[size_t(b)], p[size_t(c)], q) >= 0 && cross2(p[size_t(c)], p[size_t(a)], q) >= 0) { + inside = true; + break; + } + } + if (inside) continue; + out.push_back({f[size_t(a)], f[size_t(b)], f[size_t(c)]}); + left.erase(left.begin() + std::ptrdiff_t(i)); + cut = true; + } + if (!cut) { // (a tangled face: a fan of what's left) + for (size_t i = 1; i + 1 < left.size(); ++i) out.push_back({f[size_t(left[0])], f[size_t(left[i])], f[size_t(left[i + 1])]}); + return out; + } + } + out.push_back({f[size_t(left[0])], f[size_t(left[1])], f[size_t(left[2])]}); + return out; +} + +std::vector> edges(const Mesh& m) { + std::set> s; + for (const auto& f : m.faces) + for (size_t i = 0; i < f.size(); ++i) { + const int a = f[i], b = f[(i + 1) % f.size()]; + s.insert({std::min(a, b), std::max(a, b)}); + } + return {s.begin(), s.end()}; +} + +std::vector> cornerNormals(const Mesh& m, bool smooth, float angle) { + std::vector fn(m.faces.size()); + std::vector area(m.faces.size()); + for (size_t f = 0; f < m.faces.size(); ++f) fn[f] = faceNormal(m, int(f)), area[f] = faceArea(m, int(f)); + std::vector> out(m.faces.size()); + std::vector> round(m.verts.size()); + if (smooth) + for (size_t f = 0; f < m.faces.size(); ++f) + for (int v : m.faces[f]) round[size_t(v)].push_back(int(f)); + const float minDot = std::cos(glm::radians(angle)); + for (size_t f = 0; f < m.faces.size(); ++f) + for (int v : m.faces[f]) { + glm::vec3 n = fn[f]; + if (smooth) { + n = glm::vec3(0.0f); + for (int g : round[size_t(v)]) + if (glm::dot(fn[f], fn[size_t(g)]) >= minDot - 1e-4f) n += fn[size_t(g)] * std::max(area[size_t(g)], 1e-8f); + n = glm::length(n) > 1e-12f ? glm::normalize(n) : fn[f]; + } + out[f].push_back(n); + } + return out; +} + +glm::vec3 boundsMin(const Mesh& m) { + glm::vec3 lo(1e30f); + for (const glm::vec3& v : m.verts) lo = glm::min(lo, v); + return m.verts.empty() ? glm::vec3(0.0f) : lo; +} + +glm::vec3 boundsMax(const Mesh& m) { + glm::vec3 hi(-1e30f); + for (const glm::vec3& v : m.verts) hi = glm::max(hi, v); + return m.verts.empty() ? glm::vec3(0.0f) : hi; +} + +// ── Changing it ── + +std::vector extrudeFaces(Mesh& m, const std::set& faces, float distance) { + std::set> inSel; + std::map along; // each point of the piece: which way it moves + glm::vec3 region(0.0f); + for (int f : faces) { + const auto& fv = m.faces[size_t(f)]; + const glm::vec3 n = faceNormal(m, f); + region += n * std::max(faceArea(m, f), 1e-8f); + for (size_t i = 0; i < fv.size(); ++i) { + inSel.insert({fv[i], fv[(i + 1) % fv.size()]}); + along[fv[i]] += n; + } + } + // One flat piece moves as one, along its normal; a bent one, each point along its own. + region = glm::length(region) > 1e-12f ? glm::normalize(region) : glm::vec3(0, 0, 1); + bool flat = true; + for (int f : faces) flat &= glm::dot(faceNormal(m, f), region) > 0.99f; + std::map moved; + std::vector made; + for (auto& [v, n] : along) { + const glm::vec3 dir = flat ? region : glm::normalize(n); + moved[v] = int(m.verts.size()); + made.push_back(moved[v]); + m.verts.push_back(m.verts[size_t(v)] + dir * distance); + } + for (int f : faces) + for (int& v : m.faces[size_t(f)]) v = moved[v]; + for (auto [a, b] : inSel) // the piece's outline: a wall under each of its edges + if (!inSel.count({b, a})) m.faces.push_back({a, b, moved[b], moved[a]}); + return made; +} + +void insetFaces(Mesh& m, const std::set& faces, float amount) { + for (int f : faces) { + std::vector outer = m.faces[size_t(f)]; + const glm::vec3 c = faceCenter(m, f); + std::vector inner; + for (int v : outer) { + inner.push_back(int(m.verts.size())); + m.verts.push_back(glm::mix(m.verts[size_t(v)], c, amount)); + } + m.faces[size_t(f)] = inner; + for (size_t i = 0; i < outer.size(); ++i) { + const size_t j = (i + 1) % outer.size(); + m.faces.push_back({outer[i], outer[j], inner[j], inner[i]}); + } + } +} + +void flipFaces(Mesh& m, const std::set& faces) { + for (int f : faces) std::reverse(m.faces[size_t(f)].begin(), m.faces[size_t(f)].end()); +} + +void deleteFaces(Mesh& m, const std::set& faces) { + std::vector> keep; + for (size_t f = 0; f < m.faces.size(); ++f) + if (!faces.count(int(f))) keep.push_back(m.faces[f]); + m.faces = std::move(keep); + removeUnused(m); +} + +void deleteVerts(Mesh& m, const std::set& verts) { + std::vector to(m.verts.size()); + std::vector kept; + for (size_t v = 0; v < m.verts.size(); ++v) { + to[v] = verts.count(int(v)) ? -1 : int(kept.size()); + if (to[v] >= 0) kept.push_back(m.verts[v]); + } + remapFaces(m, to); + m.verts = std::move(kept); +} + +bool makeFace(Mesh& m, const std::vector& verts, const glm::vec3& towards) { + if (verts.size() < 3) return false; + // Round their middle, in order, on the plane they lie on. + glm::vec3 c(0.0f); + for (int v : verts) c += m.verts[size_t(v)]; + c /= float(verts.size()); + glm::vec3 n(0.0f); + for (size_t i = 0; i < verts.size(); ++i) + for (size_t j = i + 1; j < verts.size(); ++j) { + glm::vec3 k = glm::cross(m.verts[size_t(verts[i])] - c, m.verts[size_t(verts[j])] - c); + if (glm::dot(k, n) < 0.0f) k = -k; + n += k; + } + if (glm::length(n) < 1e-12f) return false; // (all in a line) + n = glm::normalize(n); + if (glm::dot(n, towards) < 0.0f) n = -n; + const glm::vec3 u = glm::normalize(std::abs(n.z) < 0.9f ? glm::cross(glm::vec3(0, 0, 1), n) : glm::cross(glm::vec3(1, 0, 0), n)); + const glm::vec3 w = glm::cross(n, u); + std::vector f = verts; + std::sort(f.begin(), f.end(), [&](int a, int b) { + const glm::vec3 pa = m.verts[size_t(a)] - c, pb = m.verts[size_t(b)] - c; + return std::atan2(glm::dot(pa, w), glm::dot(pa, u)) < std::atan2(glm::dot(pb, w), glm::dot(pb, u)); + }); + m.faces.push_back(f); + return true; +} + +int mergeByDistance(Mesh& m, float distance) { + std::vector to(m.verts.size(), -1); + std::vector kept; + for (size_t v = 0; v < m.verts.size(); ++v) { + for (size_t k = 0; k < kept.size(); ++k) + if (glm::distance(kept[k], m.verts[v]) <= distance) { + to[v] = int(k); + break; + } + if (to[v] < 0) to[v] = int(kept.size()), kept.push_back(m.verts[v]); + } + const int gone = int(m.verts.size() - kept.size()); + remapFaces(m, to); + m.verts = std::move(kept); + return gone; +} + +void mergeToCenter(Mesh& m, const std::set& verts) { + if (verts.size() < 2) return; + glm::vec3 c(0.0f); + for (int v : verts) c += m.verts[size_t(v)]; + c /= float(verts.size()); + std::vector to(m.verts.size()); + std::iota(to.begin(), to.end(), 0); + const int first = *verts.begin(); + m.verts[size_t(first)] = c; + for (int v : verts) to[size_t(v)] = first; + remapFaces(m, to); + removeUnused(m); +} + +void removeUnused(Mesh& m) { + std::vector used(m.verts.size()); + for (const auto& f : m.faces) + for (int v : f) used[size_t(v)] = true; + std::vector to(m.verts.size(), -1); + std::vector kept; + for (size_t v = 0; v < m.verts.size(); ++v) + if (used[v]) to[v] = int(kept.size()), kept.push_back(m.verts[v]); + remapFaces(m, to); + m.verts = std::move(kept); +} + +void transform(Mesh& m, const glm::mat4& matrix) { + for (glm::vec3& v : m.verts) v = glm::vec3(matrix * glm::vec4(v, 1.0f)); + if (glm::determinant(glm::mat3(matrix)) < 0.0f) // (mirrored: keep the faces facing out) + for (auto& f : m.faces) std::reverse(f.begin(), f.end()); +} + +void append(Mesh& m, const Mesh& other, const glm::mat4& matrix) { + const int base = int(m.verts.size()); + for (const glm::vec3& v : other.verts) m.verts.push_back(glm::vec3(matrix * glm::vec4(v, 1.0f))); + const bool flip = glm::determinant(glm::mat3(matrix)) < 0.0f; + for (auto f : other.faces) { + for (int& v : f) v += base; + if (flip) std::reverse(f.begin(), f.end()); + m.faces.push_back(std::move(f)); + } +} + +Mesh makeFrustum(int sides, float bottom, float top, float height) { + if (top <= 1e-6f) return makeCone(sides, bottom, height); + Mesh m = makeCylinder(sides, bottom, height); + for (glm::vec3& v : m.verts) + if (v.z > 0) v.x *= top / bottom, v.y *= top / bottom; + return m; +} + +} // namespace fry diff --git a/src/mesh.h b/src/mesh.h new file mode 100644 index 0000000..fab79b1 --- /dev/null +++ b/src/mesh.h @@ -0,0 +1,171 @@ +#pragma once +// Meshes as people think of them: points, and faces round them (any number of corners, flat or +// nearly). Fry3DX's space: metres, Z up, X forward (the model's front looks along +X), Y left — +// the same as glTF's once turned, so what you make faces the same way everywhere. +#include + +#include +#include +#include +#include + +namespace fry { + +struct Mesh { + std::vector verts; + std::vector> faces; // corners counter-clockwise, seen from outside +}; + +// ── Shapes to start from (centred on the origin) ── +Mesh makeCube(float size = 1.0f); +Mesh makePlane(float size = 1.0f); +Mesh makeCylinder(int sides = 24, float radius = 0.5f, float height = 1.0f); +Mesh makeCone(int sides = 24, float radius = 0.5f, float height = 1.0f); +// A cylinder narrower at the top (a cone if `top` is 0). +Mesh makeFrustum(int sides, float bottom, float top, float height); +Mesh makeSphere(int segments = 24, int rings = 12, float radius = 0.5f); +Mesh makeTorus(int segments = 32, int sides = 12, float radius = 0.5f, float tube = 0.15f); +// An outline (points round it, on one plane, either way round) made solid: a front and a back +// face `thickness` apart, centred on the outline's plane, and the sides between them. +Mesh extrudeOutline(const std::vector& outline, const glm::vec3& planeNormal, float thickness); + +// ── Looking at it ── +glm::vec3 faceNormal(const Mesh& m, int face); // unit (Newell's: fine for faces a little bent) +glm::vec3 faceCenter(const Mesh& m, int face); +float faceArea(const Mesh& m, int face); +// Triangles covering a face (ear clipping: works for L shapes and other dents), as corners of +// the mesh, counter-clockwise like the face. +std::vector> triangulate(const Mesh& m, int face); +// Each edge once, lower index first. +std::vector> edges(const Mesh& m); +// Every face corner's normal: flat (its face's), or smooth — averaged with the faces round the +// point that meet it at less than `angle` degrees (sharp creases stay sharp). +std::vector> cornerNormals(const Mesh& m, bool smooth, float angle); +glm::vec3 boundsMin(const Mesh& m); +glm::vec3 boundsMax(const Mesh& m); + +// ── Changing it ── +// Pulls the faces out `distance` along their normals, as one piece, with walls round the edge +// of the piece (the faces keep their numbers; returns the new points). +std::vector extrudeFaces(Mesh& m, const std::set& faces, float distance); +// Shrinks each face towards its middle by `amount` (0..1 of the way), leaving a ring of faces. +void insetFaces(Mesh& m, const std::set& faces, float amount); +void flipFaces(Mesh& m, const std::set& faces); +void deleteFaces(Mesh& m, const std::set& faces); +// Deletes points and every face that uses one. +void deleteVerts(Mesh& m, const std::set& verts); +// A new face through the points, in the order given (made to face the camera's way if `towards` +// isn't zero). False if fewer than 3. +bool makeFace(Mesh& m, const std::vector& verts, const glm::vec3& towards = glm::vec3(0.0f)); +// Points closer than `distance` become one (the first). Returns how many went. +int mergeByDistance(Mesh& m, float distance); +// Every point to their middle (merging them). +void mergeToCenter(Mesh& m, const std::set& verts); +// Drops points no face uses, renumbering the rest. +void removeUnused(Mesh& m); +void transform(Mesh& m, const glm::mat4& matrix); +// Adds another mesh's points and faces (moved by `matrix`). +void append(Mesh& m, const Mesh& other, const glm::mat4& matrix = glm::mat4(1.0f)); + +// ── More tools (tools.cpp) ── +// Rounds (or with 1 segment, cuts off) edges: each becomes a strip `width` wide across, curved +// in `segments` steps, with rounded corners where several meet. Edges lower index first; edges +// on a hole's rim are left alone. +void bevelEdges(Mesh& m, const std::set>& edges, float width, int segments); +// Edges where the faces meet sharper than `angle` degrees. +std::set> sharpEdges(const Mesh& m, float angle); +// Every face cut into four-sided pieces; `smooth` rounds it (Catmull–Clark) — edges sharper +// than `keepAngle` degrees stay sharp (180: none do). +void subdivide(Mesh& m, bool smooth, float keepAngle = 180.0f); +// The ring of four-sided faces across an edge, cut round the middle `cuts` times. Returns the new +// points (empty if the edge has no four-sided face). +std::vector loopCut(Mesh& m, std::pair edge, int cuts = 1); +// The ring of faces a loop cut through `edge` would cut (for showing it before you click). +std::vector faceRing(const Mesh& m, std::pair edge); +// The same faces' corners, each starting with the edge the ring comes in through: the cut runs +// from corners 0–1 across to corners 3–2. +std::vector> ringQuads(const Mesh& m, std::pair edge); +// The edges across that ring (each quad's way in and way out, `edge` too): a ring of edges. +std::set> edgeRing(const Mesh& m, std::pair edge); +// The line of edges an edge continues into, through points with four edges (edges lower first). +std::set> edgeLoop(const Mesh& m, std::pair edge); +// ── Cutting (cut.cpp) ── +// The knife: a path drawn on the screen, seen from `eye` (all in the mesh's own space; only the +// way from the eye to each point matters). Each step of the path sweeps a wedge out from the +// eye, and every face it passes over is split along it, edge to edge. `through`: every face in +// its way, front and back (a cut all the way through); else only faces the eye can see. +// Returns the new edges. +std::set> knifeCut(Mesh& m, const glm::vec3& eye, const std::vector& path, bool through); +// Pulls a mesh apart along edges that go all the way round it: each piece gets its own points +// there, and with `fill` a face closing each opening. Returns each piece's faces (one piece if +// the edges don't part it). +std::vector> splitAlong(Mesh& m, const std::set>& edges, bool fill); +// How much a closed piece holds (cubic metres). +float volumeOf(const Mesh& m, const std::set& faces); +// A hole's outline on a face: `sides` points round `centre` (on the face's plane), `radius` out; +// 4 sides make a square lined up with the face's longest edge. Counter-clockwise, seen from outside. +std::vector holeOutline(const Mesh& m, int face, const glm::vec3& centre, float radius, int sides); +// The hole tool's shapes, lined up with the face's longest edge, turned `turn` degrees more: +// Round: `across` wide (`sides` points), Rectangle: `across` x `tall`, Triangle: sides `across` +// long (a point up), Hexagon: `across` its flats (a nut's spanner size; flats top and bottom). +enum class HoleShape { Round, Rectangle, Triangle, Hexagon }; +std::vector holeOutline(const Mesh& m, int face, const glm::vec3& centre, HoleShape shape, float across, float tall = 0.0f, int sides = 32, + float turn = 0.0f); +// Only the outline cut into the face (nothing taken away): the face becomes a ring round a new +// face inside the outline, to extrude, bevel or paint on its own. The new face, or -1 if the +// outline doesn't fit inside the face (nothing changed). +int imprintOutline(Mesh& m, int face, const std::vector& outline); +// Cuts a hole in a face along `outline` (inside the face). depth > 0: a pocket that deep with a +// floor; depth <= 0: all the way through to the face behind (the outline must fit in that face +// too). False if it doesn't fit (nothing changed). +bool cutHole(Mesh& m, int face, const std::vector& outline, float depth); + +// ── Pictures on it (uv.cpp) ── +// Where each face corner sits on a picture (0..1 across and down): the shape is cut into +// pieces that face about the same way, each laid flat, all packed in with `margin` round them. +std::vector> autoUnwrap(const Mesh& m, float margin = 0.015f); + +// Every face joined to these by edges. +std::set linkedFaces(const Mesh& m, const std::set& faces); +std::set linkedVerts(const Mesh& m, const std::set& verts); +// The points next to these (by an edge), and these. +std::set growVerts(const Mesh& m, const std::set& verts); + +// ── Whole shapes ── + +// Faces next to each other lying flat together (normals within `angle` degrees) joined into +// one where they make a single outline; how many faces went. +int mergeFlatFaces(Mesh& m, float angle = 0.5f); +// Made `thickness` thick: a surface gets a back and walls round its open edges; a closed shape +// gets an inside (hollow, walls that thick). Negative: the new side outwards instead. +void thicken(Mesh& m, float thickness); +// Two shapes (in the same space) made one: `b` added to `a`, taken out of it, or only where they +// overlap kept. The result goes in `a`; false if nothing's left. +enum class Combine { Add, Subtract, Overlap }; +bool combine(Mesh& a, const Mesh& b, Combine op); + +// ── For 3D printing ── + +// What a slicer would mind: edges with a face on one side only (holes: not closed), edges with +// more than two faces, neighbours facing opposite ways (one turned inside out), faces with no +// area; and how much it holds, its surface, and how much of it hangs over the air (faces facing +// down more steeply than `overhang` degrees from upright, not lying on the bed at bedZ). +struct PrintCheck { + int openEdges = 0, crowdedEdges = 0, flippedEdges = 0, tinyFaces = 0, pieces = 0; + bool insideOut = false; + float volume = 0, area = 0, overhangArea = 0; // (signed volume: < 0 when inside out) + bool closed() const { return openEdges == 0 && crowdedEdges == 0 && flippedEdges == 0; } +}; +PrintCheck checkForPrint(const Mesh& m, float overhang = 45.0f, float bedZ = 0.0f); +bool overhangs(const Mesh& m, int face, float overhang, float bedZ); +// Closes each hole with a face across it; how many. +int closeHoles(Mesh& m); +// Turns faces so each piece's all face the same way, outwards; how many were turned. +int faceOutward(Mesh& m); + +// ── Its picture's layout ── + +// The frames of the layout: faces joined where they share an edge laid out the same on both. +std::vector> uvIslands(const Mesh& m, const std::vector>& uvs); + +} // namespace fry diff --git a/src/modify.cpp b/src/modify.cpp new file mode 100644 index 0000000..0ee9be9 --- /dev/null +++ b/src/modify.cpp @@ -0,0 +1,107 @@ +// Whole-shape changes: flat faces joined up, and a surface made thick. +#include "mesh.h" + +#include +#include +#include +#include +#include + +namespace fry { + +int mergeFlatFaces(Mesh& m, float angle) { + const float same = std::cos(glm::radians(angle)); + std::map, std::vector> byEdge; + for (size_t f = 0; f < m.faces.size(); ++f) { + const auto& fv = m.faces[f]; + for (size_t i = 0; i < fv.size(); ++i) byEdge[{std::min(fv[i], fv[(i + 1) % fv.size()]), std::max(fv[i], fv[(i + 1) % fv.size()])}].push_back(int(f)); + } + std::vector normal(m.faces.size()); + for (size_t f = 0; f < m.faces.size(); ++f) normal[f] = faceNormal(m, int(f)); + std::vector root(m.faces.size()); + std::iota(root.begin(), root.end(), 0); + auto find = [&](int f) { + while (root[size_t(f)] != f) f = root[size_t(f)] = root[size_t(root[size_t(f)])]; + return f; + }; + for (const auto& [e, fs] : byEdge) + if (fs.size() == 2 && glm::dot(normal[size_t(fs[0])], normal[size_t(fs[1])]) > same && + std::abs(glm::dot(normal[size_t(fs[0])], faceCenter(m, fs[1]) - faceCenter(m, fs[0]))) < 1e-5f) + root[size_t(find(fs[0]))] = find(fs[1]); + std::map> groups; + for (size_t f = 0; f < m.faces.size(); ++f) groups[find(int(f))].push_back(int(f)); + std::vector> out; + int merged = 0; + for (const auto& [r, fs] : groups) { + if (fs.size() == 1) { + out.push_back(m.faces[size_t(fs[0])]); + continue; + } + // Its outline: the edges no other face of it has the other way round. One loop that + // doesn't touch itself makes one face; otherwise the faces stay as they were. + std::set> inside; + for (int f : fs) + for (size_t i = 0; i < m.faces[size_t(f)].size(); ++i) inside.insert({m.faces[size_t(f)][i], m.faces[size_t(f)][(i + 1) % m.faces[size_t(f)].size()]}); + std::map next; + bool ok = true; + for (auto [a, b] : inside) + if (!inside.count({b, a})) ok &= next.emplace(a, b).second; + std::vector loop; + if (ok && !next.empty()) { + int v = next.begin()->first; + do { + loop.push_back(v); + v = next.count(v) ? next[v] : -1; + } while (v >= 0 && v != loop.front() && loop.size() <= next.size()); + ok = v == loop.front() && loop.size() == next.size(); + } + if (ok && loop.size() >= 3) out.push_back(loop), merged += int(fs.size()) - 1; + else + for (int f : fs) out.push_back(m.faces[size_t(f)]); + } + m.faces = out; + return merged; +} + +void thicken(Mesh& m, float thickness) { + const size_t n = m.verts.size(); + // Each point's way out: its faces' normals, weighted by their corners' angles; pushed out + // further where faces meet at an angle, so the walls come out evenly thick. + std::vector out(n, glm::vec3(0.0f)); + std::vector> around(n); + for (size_t f = 0; f < m.faces.size(); ++f) { + const auto& fv = m.faces[f]; + const glm::vec3 fn = faceNormal(m, int(f)); + for (size_t i = 0; i < fv.size(); ++i) { + const glm::vec3 p = m.verts[size_t(fv[i])], a = m.verts[size_t(fv[(i + fv.size() - 1) % fv.size()])], b = m.verts[size_t(fv[(i + 1) % fv.size()])]; + const float ang = std::acos(std::clamp(glm::dot(glm::normalize(a - p), glm::normalize(b - p)), -1.0f, 1.0f)); + out[size_t(fv[i])] += fn * ang; + around[size_t(fv[i])].push_back(fn); + } + } + for (size_t v = 0; v < n; ++v) { + if (glm::length(out[v]) < 1e-12f) continue; + out[v] = glm::normalize(out[v]); + float c = 1.0f; + for (const glm::vec3& fn : around[v]) c = std::min(c, glm::dot(fn, out[v])); + out[v] /= std::max(c, 0.35f); + } + // The other side: the points moved in (thickness > 0: in, against the faces' way), its faces + // turned round; then walls along the open edges. + for (size_t v = 0; v < n; ++v) m.verts.push_back(m.verts[v] - out[v] * thickness); + std::set> directed; + const size_t faces = m.faces.size(); + for (size_t f = 0; f < faces; ++f) + for (size_t i = 0; i < m.faces[f].size(); ++i) directed.insert({m.faces[f][i], m.faces[f][(i + 1) % m.faces[f].size()]}); + for (size_t f = 0; f < faces; ++f) { + std::vector back; + for (auto it = m.faces[f].rbegin(); it != m.faces[f].rend(); ++it) back.push_back(*it + int(n)); + m.faces.push_back(back); + } + for (auto [a, b] : directed) + if (!directed.count({b, a})) m.faces.push_back({b, a, a + int(n), b + int(n)}); + if (thickness < 0) // (made outwards: the new side is the outside) + for (auto& f : m.faces) std::reverse(f.begin(), f.end()); +} + +} // namespace fry diff --git a/src/package.cpp b/src/package.cpp new file mode 100644 index 0000000..2f58084 --- /dev/null +++ b/src/package.cpp @@ -0,0 +1,169 @@ +// Fry3DX's own file: the scene's text and its pictures, packed into one small file. +// "FRY3DX\x1a\n", u32 version, u64 size unpacked, then one zstd frame of chunks: +// u32 name length, name, u64 size, bytes. "scene": the scene as text. A picture: u32 w, u32 h, +// then its R, G, B, A planes, each pixel as the difference from what its neighbours guess +// (left, above, above-left: JPEG-LS's guess), which zstd packs far smaller than raw or PNG. +#include "scene.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace fry { +namespace { + +constexpr char kMagic[8] = {'F', 'R', 'Y', '3', 'D', 'X', '\x1a', '\n'}; +constexpr uint32_t kVersion = 1; + +uint8_t guess(int a, int b, int c) { // a left, b above, c above-left + if (c >= std::max(a, b)) return uint8_t(std::min(a, b)); + if (c <= std::min(a, b)) return uint8_t(std::max(a, b)); + return uint8_t(a + b - c); +} + +void packImage(std::string& out, const Image& im) { + const uint32_t wh[2] = {uint32_t(im.w), uint32_t(im.h)}; + out.append(reinterpret_cast(wh), 8); + const size_t at = out.size(), n = size_t(im.w) * size_t(im.h); + out.resize(at + n * 4); + for (int ch = 0; ch < 4; ++ch) { + char* plane = out.data() + at + n * size_t(ch); + auto px = [&](int x, int y) { return x < 0 || y < 0 ? 0 : int(im.px[(size_t(y) * size_t(im.w) + size_t(x)) * 4 + size_t(ch)]); }; + for (int y = 0; y < im.h; ++y) + for (int x = 0; x < im.w; ++x) + plane[size_t(y) * size_t(im.w) + size_t(x)] = char(uint8_t(px(x, y) - guess(px(x - 1, y), px(x, y - 1), px(x - 1, y - 1)))); + } +} + +bool unpackImage(const std::string& in, Image& im) { + if (in.size() < 8) return false; + uint32_t wh[2]; + std::memcpy(wh, in.data(), 8); + const size_t n = size_t(wh[0]) * size_t(wh[1]); + if (!wh[0] || !wh[1] || wh[0] > 32768 || wh[1] > 32768 || in.size() != 8 + n * 4) return false; + im.w = int(wh[0]), im.h = int(wh[1]), im.px.assign(n * 4, 0); + for (int ch = 0; ch < 4; ++ch) { + const char* plane = in.data() + 8 + n * size_t(ch); + auto px = [&](int x, int y) { return x < 0 || y < 0 ? 0 : int(im.px[(size_t(y) * size_t(im.w) + size_t(x)) * 4 + size_t(ch)]); }; + for (int y = 0; y < im.h; ++y) + for (int x = 0; x < im.w; ++x) + im.px[(size_t(y) * size_t(im.w) + size_t(x)) * 4 + size_t(ch)] = + uint8_t(uint8_t(plane[size_t(y) * size_t(im.w) + size_t(x)]) + guess(px(x - 1, y), px(x, y - 1), px(x - 1, y - 1))); + } + return true; +} + +void chunk(std::string& out, const std::string& name, const std::string& data) { + const uint32_t n = uint32_t(name.size()); + const uint64_t size = data.size(); + out.append(reinterpret_cast(&n), 4), out += name; + out.append(reinterpret_cast(&size), 8), out += data; +} + +} // namespace + +bool isPackage(const std::string& path) { + std::ifstream f(path, std::ios::binary); + char head[8] = {}; + return f.read(head, 8) && std::memcmp(head, kMagic, 8) == 0; +} + +bool savePackage(const std::string& path, const Scene& scene, int level, std::string* error) { + Scene s = scene; + std::string payload; + for (size_t i = 0; i < s.objects.size(); ++i) { + Object& o = s.objects[i]; + o.pictureFile.clear(); + if (o.picture) { + o.pictureFile = std::format("picture {}", i); + std::string px; + packImage(px, *o.picture); + chunk(payload, o.pictureFile, px); + } + for (size_t j = 0; j < o.layers.size(); ++j) { + PictureLayer& l = o.layers[j]; + l.file.clear(); + if (!l.image) continue; + l.file = std::format("layer {} {}", i, j); + std::string px; + packImage(px, *l.image); + chunk(payload, l.file, px); + } + } + chunk(payload, "scene", sceneText(s)); + + ZSTD_CCtx* cx = ZSTD_createCCtx(); + ZSTD_CCtx_setParameter(cx, ZSTD_c_compressionLevel, level); + ZSTD_CCtx_setParameter(cx, ZSTD_c_checksumFlag, 1); + if (level >= 15) ZSTD_CCtx_setParameter(cx, ZSTD_c_enableLongDistanceMatching, 1); + std::string out(20 + ZSTD_compressBound(payload.size()), '\0'); + const uint32_t lo = uint32_t(payload.size()), hi = uint32_t(uint64_t(payload.size()) >> 32); // (the u64 as two halves) + std::memcpy(out.data(), kMagic, 8), std::memcpy(out.data() + 8, &kVersion, 4), std::memcpy(out.data() + 12, &lo, 4), std::memcpy(out.data() + 16, &hi, 4); + const size_t packed = ZSTD_compress2(cx, out.data() + 20, out.size() - 20, payload.data(), payload.size()); + ZSTD_freeCCtx(cx); + if (ZSTD_isError(packed)) { + if (error) *error = ZSTD_getErrorName(packed); + return false; + } + return writeFileSafely(path, out.data(), 20 + packed, error); +} + +std::optional loadPackage(const std::string& path, std::string* error) { + auto fail = [&](const std::string& why) { + if (error) *error = why; + return std::nullopt; + }; + std::ifstream f(path, std::ios::binary); + if (!f) return fail("can't open " + path); + std::string file((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + if (file.size() < 20 || std::memcmp(file.data(), kMagic, 8) != 0) { + std::istringstream text(file); // (the old plain-text .fry) + return parseScene(text, path, error); + } + uint32_t version = 0, lo = 0, hi = 0; + std::memcpy(&version, file.data() + 8, 4), std::memcpy(&lo, file.data() + 12, 4), std::memcpy(&hi, file.data() + 16, 4); + if (version > kVersion) return fail("it was saved by a newer Fry3DX"); + const uint64_t size = uint64_t(lo) | uint64_t(hi) << 32; + if (size > (uint64_t(4) << 30)) return fail("it's damaged"); + std::string payload(size, '\0'); + ZSTD_DCtx* dx = ZSTD_createDCtx(); + ZSTD_DCtx_setParameter(dx, ZSTD_d_windowLogMax, 31); + const size_t got = ZSTD_decompressDCtx(dx, payload.data(), payload.size(), file.data() + 20, file.size() - 20); + ZSTD_freeDCtx(dx); + if (ZSTD_isError(got) || got != size) return fail("it's damaged (" + std::string(ZSTD_isError(got) ? ZSTD_getErrorName(got) : "too short") + ")"); + std::map chunks; + for (size_t at = 0; at + 4 <= payload.size();) { + uint32_t n = 0; + uint64_t len = 0; + std::memcpy(&n, payload.data() + at, 4); + if (at + 4 + n + 8 > payload.size()) return fail("it's damaged"); + std::string name = payload.substr(at + 4, n); + std::memcpy(&len, payload.data() + at + 4 + n, 8); + at += 4 + n + 8; + if (at + len > payload.size()) return fail("it's damaged"); + chunks[name] = payload.substr(at, len); + at += len; + } + std::istringstream text(chunks["scene"]); + auto s = parseScene(text, path, error); + if (!s) return s; + for (Object& o : s->objects) { + Image im; + if (!o.pictureFile.empty() && unpackImage(chunks[o.pictureFile], im)) o.picture = std::make_shared(std::move(im)); + o.pictureFile.clear(); + for (PictureLayer& l : o.layers) { + Image li; + if (!l.file.empty() && unpackImage(chunks[l.file], li)) l.image = std::make_shared(std::move(li)); + l.file.clear(); + } + std::erase_if(o.layers, [](const PictureLayer& l) { return !l.image; }); + } + return s; +} + +} // namespace fry diff --git a/src/print.cpp b/src/print.cpp new file mode 100644 index 0000000..a8dd086 --- /dev/null +++ b/src/print.cpp @@ -0,0 +1,122 @@ +// Checking and fixing models for 3D printing. +#include "mesh.h" + +#include +#include +#include +#include + +namespace fry { + +bool overhangs(const Mesh& m, int face, float overhang, float bedZ) { + if (faceNormal(m, face).z > -std::sin(glm::radians(90.0f - overhang))) return false; + for (int v : m.faces[size_t(face)]) + if (m.verts[size_t(v)].z > bedZ + 1e-4f) return true; + return false; // (lying on the bed) +} + +PrintCheck checkForPrint(const Mesh& m, float overhang, float bedZ) { + PrintCheck c; + std::map, int> directed; // (how many faces go a -> b) + for (const auto& f : m.faces) + for (size_t i = 0; i < f.size(); ++i) ++directed[{f[i], f[(i + 1) % f.size()]}]; + std::set> seen; + for (const auto& [e, n] : directed) { + const std::pair key{std::min(e.first, e.second), std::max(e.first, e.second)}; + if (!seen.insert(key).second) continue; + const int ab = n, ba = directed.count({e.second, e.first}) ? directed.at({e.second, e.first}) : 0; + if (ab + ba == 1) ++c.openEdges; + else if (ab + ba > 2) ++c.crowdedEdges; + else if (ab == 2 || ba == 2) ++c.flippedEdges; + } + for (size_t f = 0; f < m.faces.size(); ++f) { + const float a = faceArea(m, int(f)); + c.area += a; + if (a < 1e-10f) ++c.tinyFaces; + else if (overhangs(m, int(f), overhang, bedZ)) c.overhangArea += a; + for (const auto& t : triangulate(m, int(f))) c.volume += glm::dot(m.verts[size_t(t[0])], glm::cross(m.verts[size_t(t[1])], m.verts[size_t(t[2])])) / 6.0f; + } + std::set left; + for (size_t f = 0; f < m.faces.size(); ++f) left.insert(int(f)); + while (!left.empty()) { + const std::set piece = linkedFaces(m, {*left.begin()}); + for (int f : piece) left.erase(f); + ++c.pieces; + } + c.insideOut = c.volume < 0; + return c; +} + +int closeHoles(Mesh& m) { + std::set> directed; + for (const auto& f : m.faces) + for (size_t i = 0; i < f.size(); ++i) directed.insert({f[i], f[(i + 1) % f.size()]}); + std::map next; // (round each hole: a boundary edge a -> b, then on from b) + for (const auto& [a, b] : directed) + if (!directed.count({b, a})) next[a] = b; + int holes = 0; + while (!next.empty()) { + std::vector loop; + int v = next.begin()->first; + while (next.count(v)) { + loop.push_back(v); + const int n = next[v]; + next.erase(v); + v = n; + } + if (loop.size() < 3 || v != loop.front()) continue; // (not a clean loop) + std::reverse(loop.begin(), loop.end()); // (the cap goes round the other way to the faces beside it) + m.faces.push_back(loop); + ++holes; + } + return holes; +} + +int faceOutward(Mesh& m) { + std::map, std::vector> byEdge; + for (size_t f = 0; f < m.faces.size(); ++f) { + const auto& fv = m.faces[f]; + for (size_t i = 0; i < fv.size(); ++i) byEdge[{std::min(fv[i], fv[(i + 1) % fv.size()]), std::max(fv[i], fv[(i + 1) % fv.size()])}].push_back(int(f)); + } + auto goes = [&](int f, int a, int b) { // (face f has the edge a -> b that way round) + const auto& fv = m.faces[size_t(f)]; + for (size_t i = 0; i < fv.size(); ++i) + if (fv[i] == a && fv[(i + 1) % fv.size()] == b) return true; + return false; + }; + int turned = 0; + std::vector done(m.faces.size(), 0); + for (size_t start = 0; start < m.faces.size(); ++start) { + if (done[start]) continue; + std::vector piece{int(start)}, todo{int(start)}; + int flips = 0; + done[start] = 1; + while (!todo.empty()) { + const int f = todo.back(); + todo.pop_back(); + const auto fv = m.faces[size_t(f)]; + for (size_t i = 0; i < fv.size(); ++i) { + const int a = fv[i], b = fv[(i + 1) % fv.size()]; + const auto& around = byEdge[{std::min(a, b), std::max(a, b)}]; + if (around.size() != 2) continue; // (only across clean edges) + for (int g : around) { + if (done[size_t(g)]) continue; + done[size_t(g)] = 1; + if (goes(g, a, b)) std::reverse(m.faces[size_t(g)].begin(), m.faces[size_t(g)].end()), ++flips; + piece.push_back(g), todo.push_back(g); + } + } + } + float v = 0; + for (int f : piece) + for (const auto& t : triangulate(m, f)) v += glm::dot(m.verts[size_t(t[0])], glm::cross(m.verts[size_t(t[1])], m.verts[size_t(t[2])])); + if (v < 0) { // (all of it inside out: turned round, those just turned are back as they were) + for (int f : piece) std::reverse(m.faces[size_t(f)].begin(), m.faces[size_t(f)].end()); + flips = int(piece.size()) - flips; + } + turned += flips; + } + return turned; +} + +} // namespace fry diff --git a/src/scene.cpp b/src/scene.cpp new file mode 100644 index 0000000..5f940d6 --- /dev/null +++ b/src/scene.cpp @@ -0,0 +1,454 @@ +#include "scene.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fry { + +glm::quat fromEuler(const glm::vec3& degrees) { + const glm::vec3 r = glm::radians(degrees); + return glm::angleAxis(r.z, glm::vec3(0, 0, 1)) * glm::angleAxis(r.y, glm::vec3(0, 1, 0)) * glm::angleAxis(r.x, glm::vec3(1, 0, 0)); +} + +glm::vec3 toEuler(const glm::quat& q) { + const glm::mat3 m = glm::mat3_cast(glm::normalize(q)); // = Rz Ry Rx; m[column][row] + const float y = std::asin(std::clamp(-m[0][2], -1.0f, 1.0f)); + float x, z; + if (std::abs(m[0][2]) < 0.9999f) x = std::atan2(m[1][2], m[2][2]), z = std::atan2(m[0][1], m[0][0]); + else x = std::atan2(-m[2][1], m[1][1]), z = 0.0f; // (straight up or down: X and Z turn the same way) + return glm::degrees(glm::vec3(x, y, z)); +} + +glm::mat4 objectMatrix(const Object& o) { + return glm::translate(glm::mat4(1.0f), o.position) * glm::mat4_cast(o.rotation) * glm::scale(glm::mat4(1.0f), o.scale); +} + +Mesh finishedMesh(const Object& o, bool mirrorOnly) { + Mesh m = o.mesh; + for (int axis = 0; axis < 3; ++axis) { + if (!(o.mirror & (1 << axis))) continue; + glm::vec3 s(1.0f); + s[axis] = -1.0f; + const size_t before = m.verts.size(); + append(m, Mesh(m), glm::scale(glm::mat4(1.0f), s)); + // Points on the mirror line join their copies (so the halves are one piece there). + const float eps = 1e-4f; + std::vector to(m.verts.size()); + for (size_t i = 0; i < to.size(); ++i) to[i] = int(i); + for (size_t i = before; i < m.verts.size(); ++i) + if (std::abs(m.verts[i][axis]) < eps) to[i] = int(i - before); + for (auto& f : m.faces) + for (int& v : f) v = to[size_t(v)]; + std::erase_if(m.faces, [](const std::vector& f) { return std::set(f.begin(), f.end()).size() < 3; }); + // (a face lying on the mirror line would now be there twice, back to back: both go) + std::map, int> seen; + for (size_t f = 0; f < m.faces.size(); ++f) { + std::vector k = m.faces[f]; + std::sort(k.begin(), k.end()); + seen[k]++; + } + std::erase_if(m.faces, [&](const std::vector& f) { + std::vector k = f; + std::sort(k.begin(), k.end()); + return seen[k] > 1; + }); + removeUnused(m); + } + if (mirrorOnly) return m; + if (o.round > 0.0f) bevelEdges(m, sharpEdges(m, o.roundAngle), o.round, o.roundSteps); + for (int i = 0; i < std::min(o.smoothLevels, 4); ++i) subdivide(m, true, o.smoothKeepSharp ? 30.0f : 180.0f); + return m; +} + +std::array referenceCorners(const Reference& r, float aspect) { + // Each view's right, up and towards-you directions (see the cameras in app.cpp). + glm::vec3 right, up; + switch (r.axis) { + case Axis::Front: right = {0, 1, 0}, up = {0, 0, 1}; break; + case Axis::Side: right = {-1, 0, 0}, up = {0, 0, 1}; break; + default: right = {0, -1, 0}, up = {1, 0, 0}; break; + } + const glm::vec3 c = right * r.offset.x + up * r.offset.y; + const glm::vec3 x = right * (r.width * 0.5f), y = up * (std::abs(r.width) * aspect * 0.5f); + return {c - x - y, c + x - y, c + x + y, c - x + y}; +} + +// ── .fry ── + +namespace { +std::string quoted(const std::string& s) { + std::string out = "\""; + for (char ch : s) { + if (ch == '"' || ch == '\\') out += '\\'; + out += ch; + } + return out + "\""; +} + +std::string readQuoted(std::istream& in) { + std::string out; + char ch; + in >> std::ws; + if (in.peek() != '"') { + in >> out; + return out; + } + in.get(); + while (in.get(ch) && ch != '"') { + if (ch == '\\') in.get(ch); + out += ch; + } + return out; +} + +const char* axisName(Axis a) { return a == Axis::Front ? "front" : a == Axis::Side ? "side" : "top"; } +} // namespace + +std::string sceneText(const Scene& scene) { + std::ostringstream out; + out.precision(9); // (floats come back exactly) + out << "fry3dx 1\n"; + const Look& k = scene.look; + out << "look " << k.sunTurn << " " << k.sunHeight << " " << k.sunStrength << " " << k.sunColor.r << " " << k.sunColor.g << " " << k.sunColor.b << " " << k.sky + << " " << k.ground << " " << k.background.r << " " << k.background.g << " " << k.background.b << " " << k.groundColor.r << " " << k.groundColor.g << " " + << k.groundColor.b << " " << k.exposure << " " << k.ao << " " << k.bloom << " " << k.saturation << " " << k.vignette << " " << k.sharpen << " " + << k.shadowSoft << "\n"; + for (const Reference& r : scene.references) + out << "reference " << quoted(r.path) << " " << axisName(r.axis) << " " << r.offset.x << " " << r.offset.y << " " << r.width << " " << r.opacity + << " " << r.visible << "\n"; + // Folders by their place in the file (1 up; 0 the top), as ids change on loading. + std::map folderAt; + for (size_t i = 0; i < scene.folders.size(); ++i) folderAt[scene.folders[i].id] = i + 1; + for (const Folder& f : scene.folders) + out << "folder " << quoted(f.name) << " " << (folderAt.count(f.parent) ? folderAt[f.parent] : 0) << " " << f.open << " " << f.visible << " " << f.locked << "\n"; + for (const Object& o : scene.objects) { + out << "object " << quoted(o.name) << "\n"; + if (folderAt.count(o.folder)) out << " folder " << folderAt[o.folder] << "\n"; + out << " position " << o.position.x << " " << o.position.y << " " << o.position.z << "\n"; + out << " rotation " << o.rotation.w << " " << o.rotation.x << " " << o.rotation.y << " " << o.rotation.z << "\n"; // (a quaternion: w x y z) + out << " scale " << o.scale.x << " " << o.scale.y << " " << o.scale.z << "\n"; + out << " color " << o.color.r << " " << o.color.g << " " << o.color.b << "\n"; + out << " metal " << o.metal << "\n rough " << o.rough << "\n"; + out << " smooth " << o.smooth << " " << o.smoothAngle << "\n"; + out << " visible " << o.visible << "\n locked " << o.locked << "\n"; + if (!o.recipe.empty()) { + out << " recipe " << o.recipe; + for (float v : o.settings) out << " " << v; + out << "\n"; + } + if (o.mirror) out << " mirror " << o.mirror << "\n"; + if (o.round > 0.0f) out << " round " << o.round << " " << o.roundSteps << " " << o.roundAngle << "\n"; + if (o.smoothLevels) out << " subdivide " << o.smoothLevels << " " << o.smoothKeepSharp << "\n"; + if (!o.pictureFile.empty()) out << " picture " << quoted(o.pictureFile) << "\n"; + for (const PictureLayer& l : o.layers) { + if (l.file.empty()) continue; + out << " layer " << quoted(l.name) << " " << quoted(l.file) << " " << l.sticker << " " << l.visible << " " << l.opacity; + for (const glm::vec2& c : l.corners) out << " " << c.x << " " << c.y; + out << " " << l.face << " " << l.centre.x << " " << l.centre.y << " " << l.centre.z << " " << l.up.x << " " << l.up.y << " " << l.up.z << " " << l.width << " " + << l.height << "\n"; + } + for (const glm::vec3& v : o.mesh.verts) out << " v " << v.x << " " << v.y << " " << v.z << "\n"; + for (const auto& f : o.mesh.faces) { + out << " f"; + for (int i : f) out << " " << i; + out << "\n"; + } + if (o.picture && o.uvsFit()) + for (const auto& t : o.uvs) { + out << " t"; + for (const glm::vec2& q : t) out << " " << q.x << " " << q.y; + out << "\n"; + } + out << "end\n"; + } + return out.str(); +} + +bool writeFileSafely(const std::string& path, const void* data, size_t size, std::string* error) { + // Written beside it, then moved over it: a crash mid-save never leaves half a file. + const std::string tmp = path + ".tmp"; + { + std::ofstream f(tmp, std::ios::binary); + if (!f.write(static_cast(data), std::streamsize(size)) || !(f.flush())) { + if (error) *error = "can't write " + tmp; + return false; + } + } + std::error_code ec; + std::filesystem::rename(tmp, path, ec); + if (ec && error) *error = ec.message(); + return !ec; +} + +bool saveScene(const std::string& path, const Scene& scene, std::string* error) { + const std::string text = sceneText(scene); + return writeFileSafely(path, text.data(), text.size(), error); +} + +std::optional loadScene(const std::string& path, std::string* error) { + std::ifstream in(path); + if (!in) { + if (error) *error = "can't open " + path; + return std::nullopt; + } + return parseScene(in, path, error); +} + +std::optional parseScene(std::istream& in, const std::string& path, std::string* error) { + std::string word; + int version = 0; + if (!(in >> word >> version) || word != "fry3dx") { + if (error) *error = path + " isn't a Fry3DX file"; + return std::nullopt; + } + Scene s; + Object* o = nullptr; + for (std::string line; std::getline(in, line);) { + std::istringstream l(line); + if (!(l >> word)) continue; + if (word == "reference") { + Reference r; + r.id = s.nextId++; + r.path = readQuoted(l); + std::string axis; + l >> axis >> r.offset.x >> r.offset.y >> r.width >> r.opacity >> r.visible; + r.axis = axis == "front" ? Axis::Front : axis == "top" ? Axis::Top : Axis::Side; + s.references.push_back(r); + } else if (word == "look") { + Look& k = s.look; + l >> k.sunTurn >> k.sunHeight >> k.sunStrength >> k.sunColor.r >> k.sunColor.g >> k.sunColor.b >> k.sky >> k.ground >> k.background.r >> k.background.g >> + k.background.b >> k.groundColor.r >> k.groundColor.g >> k.groundColor.b >> k.exposure >> k.ao >> k.bloom >> k.saturation >> k.vignette >> k.sharpen >> + k.shadowSoft; + } else if (word == "folder" && !o) { + Folder f; + f.id = s.nextId++; + f.name = readQuoted(l); + size_t parent = 0; + l >> parent >> f.open >> f.visible >> f.locked; + f.parent = uint32_t(parent); // (a place in the file for now) + s.folders.push_back(f); + } else if (word == "object") { + s.objects.emplace_back(); + o = &s.objects.back(); + o->id = s.nextId++; + o->name = readQuoted(l); + } else if (!o) { + continue; + } else if (word == "position") l >> o->position.x >> o->position.y >> o->position.z; + else if (word == "rotation") l >> o->rotation.w >> o->rotation.x >> o->rotation.y >> o->rotation.z; + else if (word == "scale") l >> o->scale.x >> o->scale.y >> o->scale.z; + else if (word == "color") l >> o->color.r >> o->color.g >> o->color.b; + else if (word == "metal") l >> o->metal; + else if (word == "rough") l >> o->rough; + else if (word == "smooth") l >> o->smooth >> o->smoothAngle; + else if (word == "folder") { + size_t at = 0; + l >> at; + o->folder = uint32_t(at); // (a place in the file for now) + } else if (word == "visible") l >> o->visible; + else if (word == "locked") l >> o->locked; + else if (word == "recipe") { + l >> o->recipe; + for (float v; l >> v;) o->settings.push_back(v); + } else if (word == "mirror") l >> o->mirror; + else if (word == "round") l >> o->round >> o->roundSteps >> o->roundAngle; + else if (word == "subdivide") l >> o->smoothLevels >> o->smoothKeepSharp; + else if (word == "picture") o->pictureFile = readQuoted(l); + else if (word == "layer") { + PictureLayer p; + p.name = readQuoted(l); + l >> std::ws; + p.file = readQuoted(l); + l >> p.sticker >> p.visible >> p.opacity; + for (glm::vec2& c : p.corners) l >> c.x >> c.y; + l >> p.face >> p.centre.x >> p.centre.y >> p.centre.z >> p.up.x >> p.up.y >> p.up.z >> p.width >> p.height; + o->layers.push_back(p); + } + else if (word == "t") { + std::vector t; + for (glm::vec2 q; l >> q.x >> q.y;) t.push_back(q); + o->uvs.push_back(t); + } + else if (word == "v") { + glm::vec3 v; + l >> v.x >> v.y >> v.z; + o->mesh.verts.push_back(v); + } else if (word == "f") { + std::vector f; + for (int i; l >> i;) + if (i >= 0 && i < int(o->mesh.verts.size())) f.push_back(i); + if (f.size() >= 3) o->mesh.faces.push_back(f); + } else if (word == "end") o = nullptr; + } + // Places in the file to ids (a parent or folder that isn't there: the top). + auto idAt = [&](uint32_t at) { return at >= 1 && at <= s.folders.size() ? s.folders[at - 1].id : 0u; }; + for (Folder& f : s.folders) f.parent = idAt(f.parent); + for (Folder& f : s.folders) // (a folder inside itself, round a loop: put at the top) + for (uint32_t p = f.parent, n = 0; p; ++n) { + if (p == f.id || n > s.folders.size()) { + f.parent = 0; + break; + } + p = std::find_if(s.folders.begin(), s.folders.end(), [&](const Folder& g) { return g.id == p; })->parent; + } + for (Object& o : s.objects) o.folder = idAt(o.folder); + return s; +} + +// ── glTF ── + +bool exportGltf(const std::string& path, const Scene& scene, const std::vector>& pngs, bool binary, std::string* error) { + // Fry3DX: X forward, Y left, Z up. glTF: Z forward, X left, Y up. + auto toGltf = [](glm::vec3 v) { return glm::vec3(v.y, v.z, v.x); }; + std::vector bin; + auto put = [&](const void* p, size_t n) { + while (bin.size() % 4) bin.push_back(0); + const size_t at = bin.size(); + bin.insert(bin.end(), (const uint8_t*)p, (const uint8_t*)p + n); + return at; + }; + std::ostringstream views, accessors, meshes, nodes, materials, images; + int nImage = 0; + int nView = 0, nAcc = 0, nMesh = 0; + std::vector roots; + auto view = [&](size_t offset, size_t length, int target) { + views << (nView ? "," : "") << "{\"buffer\":0,\"byteOffset\":" << offset << ",\"byteLength\":" << length; + if (target) views << ",\"target\":" << target; + views << "}"; + return nView++; + }; + auto escape = [](const std::string& s) { + std::string out; + for (char ch : s) { + if (ch == '"' || ch == '\\') out += '\\'; + if (uint8_t(ch) >= 0x20) out += ch; + } + return out; + }; + const std::filesystem::path p(path); + for (size_t oi = 0; oi < scene.objects.size(); ++oi) { + const Object& shown = scene.objects[oi]; + if (!shown.visible || shown.mesh.faces.empty()) continue; + Object o = shown; + if (o.hasFinish()) o.mesh = finishedMesh(shown); + // Painted: its picture, laid on as painted (not with rounding or smoothing added since). + const bool painted = oi < pngs.size() && !pngs[oi].empty() && shown.uvsFit() && !shown.hasFinish(); + // Its turn and size go into its points; its place stays the node's (its origin: a hinge). + const glm::mat4 rs = glm::mat4_cast(o.rotation) * glm::scale(glm::mat4(1.0f), o.scale); + const glm::mat3 nm = glm::transpose(glm::inverse(glm::mat3(rs))); + const bool mirrored = glm::determinant(glm::mat3(rs)) < 0.0f; + const auto normals = cornerNormals(o.mesh, o.smooth, o.smoothAngle); + std::vector pos, nrm; + std::vector uv; + std::vector idx; + std::map, uint32_t> seen; // (shared corners where the normal and picture agree) + for (size_t f = 0; f < o.mesh.faces.size(); ++f) { + const auto& face = o.mesh.faces[f]; + for (const auto& tri : triangulate(o.mesh, int(f))) { + std::array t{}; + for (int k = 0; k < 3; ++k) { + const size_t corner = size_t(std::find(face.begin(), face.end(), tri[size_t(k)]) - face.begin()); + const glm::vec3 p = toGltf(glm::vec3(rs * glm::vec4(o.mesh.verts[size_t(tri[size_t(k)])], 1.0f))); + const glm::vec3 n = toGltf(glm::normalize(nm * normals[f][corner])); + // Pictures wrap round it from the side it faces most (1 m = once), or as painted. + const glm::vec3 a = glm::abs(n); + const glm::vec2 tc = painted ? o.uvs[f][corner] + : a.x >= a.y && a.x >= a.z ? glm::vec2(p.z, -p.y) : a.y >= a.z ? glm::vec2(p.x, -p.z) : glm::vec2(p.x, -p.y); + const std::array key{p.x, p.y, p.z, n.x, n.y, n.z, tc.x, tc.y}; + auto [it, fresh] = seen.try_emplace(key, uint32_t(pos.size())); + if (fresh) { + uv.push_back(tc); + pos.push_back(p), nrm.push_back(n); + } + t[size_t(k)] = it->second; + } + if (mirrored) std::swap(t[1], t[2]); + idx.insert(idx.end(), t.begin(), t.end()); + } + } + glm::vec3 lo(1e30f), hi(-1e30f); + for (const glm::vec3& p : pos) lo = glm::min(lo, p), hi = glm::max(hi, p); + const int vp = view(put(pos.data(), pos.size() * 12), pos.size() * 12, 34962); + const int vn = view(put(nrm.data(), nrm.size() * 12), nrm.size() * 12, 34962); + const int vu = view(put(uv.data(), uv.size() * 8), uv.size() * 8, 34962); + const int vi = view(put(idx.data(), idx.size() * 4), idx.size() * 4, 34963); + accessors << (nAcc ? "," : "") << "{\"bufferView\":" << vp << ",\"componentType\":5126,\"count\":" << pos.size() << ",\"type\":\"VEC3\",\"min\":[" + << lo.x << "," << lo.y << "," << lo.z << "],\"max\":[" << hi.x << "," << hi.y << "," << hi.z << "]}" + << ",{\"bufferView\":" << vn << ",\"componentType\":5126,\"count\":" << nrm.size() << ",\"type\":\"VEC3\"}" + << ",{\"bufferView\":" << vu << ",\"componentType\":5126,\"count\":" << uv.size() << ",\"type\":\"VEC2\"}" + << ",{\"bufferView\":" << vi << ",\"componentType\":5125,\"count\":" << idx.size() << ",\"type\":\"SCALAR\"}"; + meshes << (nMesh ? "," : "") << "{\"name\":\"" << escape(o.name) << "\",\"primitives\":[{\"attributes\":{\"POSITION\":" << nAcc + << ",\"NORMAL\":" << nAcc + 1 << ",\"TEXCOORD_0\":" << nAcc + 2 << "},\"indices\":" << nAcc + 3 << ",\"material\":" << nMesh << "}]}"; + nAcc += 4; + const glm::vec3 lin = painted ? glm::vec3(1.0f) : glm::pow(o.color, glm::vec3(2.2f)); // (picked as seen, sRGB; glTF's factor is linear) + materials << (nMesh ? "," : "") << "{\"name\":\"" << escape(o.name) << "\",\"pbrMetallicRoughness\":{\"baseColorFactor\":[" << lin.r << "," + << lin.g << "," << lin.b << ",1],"; + if (painted) { + materials << "\"baseColorTexture\":{\"index\":" << nImage << "},"; + if (binary) images << (nImage ? "," : "") << "{\"mimeType\":\"image/png\",\"bufferView\":" << view(put(pngs[oi].data(), pngs[oi].size()), pngs[oi].size(), 0) << "}"; + else { + const std::string name = std::format("{}_{}.png", p.stem().string(), oi + 1); + std::ofstream f(p.parent_path() / name, std::ios::binary); + if (!f.write(reinterpret_cast(pngs[oi].data()), std::streamsize(pngs[oi].size()))) { + if (error) *error = "can't write " + name; + return false; + } + images << (nImage ? "," : "") << "{\"uri\":\"" << escape(name) << "\"}"; + } + ++nImage; + } + materials << "\"metallicFactor\":" << o.metal << ",\"roughnessFactor\":" << o.rough << "}}"; + const glm::vec3 t = toGltf(o.position); + nodes << (nMesh ? "," : "") << "{\"name\":\"" << escape(o.name) << "\",\"mesh\":" << nMesh << ",\"translation\":[" << t.x << "," << t.y << "," + << t.z << "]}"; + roots.push_back(nMesh); + ++nMesh; + } + if (!nMesh) { + if (error) *error = "nothing to export (no visible objects)"; + return false; + } + const std::string binName = p.stem().string() + ".bin"; + std::ostringstream j; + j << "{\"asset\":{\"version\":\"2.0\",\"generator\":\"Fry3DX\"},\"scene\":0,\"scenes\":[{\"nodes\":["; + for (size_t i = 0; i < roots.size(); ++i) j << (i ? "," : "") << roots[i]; + j << "]}],\"nodes\":[" << nodes.str() << "],\"meshes\":[" << meshes.str() << "],\"materials\":[" << materials.str() << "],\"accessors\":[" + << accessors.str() << "]"; + if (nImage) { + j << ",\"samplers\":[{}],\"images\":[" << images.str() << "],\"textures\":["; + for (int i = 0; i < nImage; ++i) j << (i ? "," : "") << "{\"sampler\":0,\"source\":" << i << "}"; + j << "]"; + } + j << ",\"bufferViews\":[" << views.str() << "],\"buffers\":[{"; + if (!binary) j << "\"uri\":\"" << escape(binName) << "\","; + j << "\"byteLength\":" << bin.size() << "}]}"; + if (binary) { // .glb: a header, the JSON, then the binary, each padded to 4 bytes + std::string json = j.str(); + while (json.size() % 4) json += ' '; + while (bin.size() % 4) bin.push_back(0); + std::vector out; + auto u32 = [&](uint32_t v) { out.insert(out.end(), reinterpret_cast(&v), reinterpret_cast(&v) + 4); }; + u32(0x46546C67), u32(2), u32(uint32_t(12 + 8 + json.size() + 8 + bin.size())); + u32(uint32_t(json.size())), u32(0x4E4F534A), out.insert(out.end(), json.begin(), json.end()); + u32(uint32_t(bin.size())), u32(0x004E4942), out.insert(out.end(), bin.begin(), bin.end()); + return writeFileSafely(path, out.data(), out.size(), error); + } + j << "\n"; + std::ofstream jf(path, std::ios::binary), bf((p.parent_path() / binName).string(), std::ios::binary); + if (!(jf << j.str()) || !bf.write((const char*)bin.data(), std::streamsize(bin.size()))) { + if (error) *error = "can't write " + path; + return false; + } + return true; +} + +} // namespace fry diff --git a/src/scene.h b/src/scene.h new file mode 100644 index 0000000..b68bd6e --- /dev/null +++ b/src/scene.h @@ -0,0 +1,166 @@ +#pragma once +// What you're making: objects (a mesh each, placed, coloured) and reference pictures to trace. +// Saved as .fry (plain text), exported as glTF for games (Blender, Godot, Unity). +#include "image.h" +#include "mesh.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace fry { + +// A picture put onto an object's painting, kept apart from it (a layer) so it can be moved again. +// A sticker sits on the surface: `centre` on face `face` (the object's own space), `up` its top's +// way, `width` x `height` across, and wraps over edges like a real one. Otherwise it's pinned onto +// the flat picture by its four corners (pixels: top-left, top-right, bottom-right, bottom-left). +struct PictureLayer { + std::string name; + std::shared_ptr image; + bool sticker = true, visible = true; + float opacity = 1.0f; + std::array corners{}; + int face = 0; + glm::vec3 centre{0.0f}, up{0.0f, 0.0f, 1.0f}; + float width = 0.5f, height = 0.5f; + std::string file; // (its PNG's name, set when saving) +}; + +// A folder in the Layers panel: objects (and folders) kept together, and picked together (a group). +struct Folder { + uint32_t id = 0; + std::string name; + uint32_t parent = 0; // (0: at the top) + bool open = true, visible = true, locked = false; +}; + +struct Object { + uint32_t id = 0; + std::string name; + uint32_t folder = 0; // (0: at the top) + Mesh mesh; // around its origin (the point it turns and scales about) + glm::vec3 position{0.0f}; // metres + glm::quat rotation{1.0f, 0.0f, 0.0f, 0.0f}; + glm::vec3 scale{1.0f}; + glm::vec3 color{0.62f, 0.62f, 0.64f}; // as picked (sRGB) + float metal = 0.0f, rough = 0.5f; + bool smooth = false; // smooth shading, creases sharper than smoothAngle kept + float smoothAngle = 35.0f; + bool visible = true, locked = false; + // A shape from the library keeps its recipe and settings (changeable in Properties) until + // its shape is edited by hand. + std::string recipe; + std::vector settings; + // Finishing touches, not baked into the mesh until applied: mirrored copies across its own + // X / Y / Z (bits 1, 2, 4), rounded edges (those sharper than roundAngle), smoothing. + int mirror = 0; + float round = 0.0f, roundAngle = 30.0f; + int roundSteps = 3; + int smoothLevels = 0; + bool smoothKeepSharp = false; + bool hasFinish() const { return mirror || round > 0.0f || smoothLevels > 0; } + // A painted picture on it: where each face corner sits on it (0..1), and the picture + // (shared with the undo steps until it's painted on again). Saved beside the .fry as a PNG. + std::vector> uvs; + std::shared_ptr picture; + std::string pictureFile; // (the PNG's name, set when saving or exporting) + std::vector layers; // on top of the painting, bottom first + bool uvsFit() const { + if (uvs.size() != mesh.faces.size()) return false; + for (size_t f = 0; f < uvs.size(); ++f) + if (uvs[f].size() != mesh.faces[f].size()) return false; + return true; + } +}; + +// The picture as it shows: the painting with its layers on top. owner (if given): for each pixel, +// which is on top there (0 the painting, i + 1 layer i). +Image composePicture(const Object& o, std::vector* owner = nullptr); +// A sticker's face (the one it was put on, or the nearest if the shape's changed), and its axes +// there: right, up (along the face), and the face's normal. +int stickerFace(const Mesh& m, const PictureLayer& l); +bool stickerFrame(const Mesh& m, const PictureLayer& l, glm::vec3& right, glm::vec3& up, glm::vec3& normal); +// A sticker whose middle has gone past its face's edge moves onto the face its middle is on now +// (bent over the same way): it lies on the face with most of it and bends from there. +void settleSticker(const Mesh& m, PictureLayer& l); +// A sticker painted onto a picture (uvs: the object's), unfolded across edges from its face. +void stampSticker(Image& dst, const Mesh& m, const std::vector>& uvs, const PictureLayer& l, std::vector* owner = nullptr, uint8_t id = 0); + +// After frames of the layout moved (`before` to the object's uvs now): what was painted on those +// faces moved with them, so the model looks the same. +void remapPicture(Image& img, const Mesh& m, const std::vector>& before, const std::vector>& after, + const std::vector& faces); + +// The mesh as shown and exported: with its mirror, rounding and smoothing. +Mesh finishedMesh(const Object& o, bool mirrorOnly = false); + +// A picture to trace over, standing on the plane through the origin that a view looks at. +enum class Axis { Front, Side, Top }; // the view it faces: from +X, from +Y (the left), from above +struct Reference { + uint32_t id = 0; + std::string path; // the picture file (PNG, JPG...) + Axis axis = Axis::Side; + glm::vec2 offset{0.0f}; // its middle on that plane, metres + float width = 1.0f; // metres across + float opacity = 0.6f; + bool visible = true; +}; + +// The Render tab's light and look. +struct Look { + float sunTurn = 35.0f, sunHeight = 50.0f, sunStrength = 0.6f; // degrees round (from +X), degrees up + glm::vec3 sunColor{1.0f, 0.96f, 0.9f}; + bool sky = true, ground = true; + glm::vec3 background{0.2f, 0.2f, 0.215f}, groundColor{0.33f, 0.33f, 0.35f}; // (sRGB) + float exposure = 1.0f, ao = 1.0f, bloom = 0.035f, saturation = 1.05f, vignette = 0.25f, sharpen = 0.3f, shadowSoft = 1.0f; +}; + +struct Scene { + Look look; + std::vector objects; + std::vector folders; + std::vector references; + uint32_t nextId = 1; +}; + +glm::mat4 objectMatrix(const Object& o); +// A turn as degrees about X, then Y, then Z (what the Properties panel shows), and back. +glm::vec3 toEuler(const glm::quat& q); +glm::quat fromEuler(const glm::vec3& degrees); +// Points of a reference's picture: its four corners (counter-clockwise, as seen from its view), +// given its aspect (height / width). +std::array referenceCorners(const Reference& r, float aspect); + +// The scene as plain text (the old .fry, and what's inside a new one), pictures by file name. +std::string sceneText(const Scene& scene); +std::optional parseScene(std::istream& in, const std::string& path, std::string* error = nullptr); +bool saveScene(const std::string& path, const Scene& scene, std::string* error = nullptr); +std::optional loadScene(const std::string& path, std::string* error = nullptr); +// Written beside it then moved over it: a crash mid-save never leaves half a file. +bool writeFileSafely(const std::string& path, const void* data, size_t size, std::string* error = nullptr); + +// Fry3DX's own file (.fry): one file, the scene and every picture in it, packed small (pictures +// as colour planes of differences, then all of it zstd'd). Opens the old text .fry files too. +// level: zstd's, 1 (quick) to 19 (smallest). +bool savePackage(const std::string& path, const Scene& scene, int level = 12, std::string* error = nullptr); +std::optional loadPackage(const std::string& path, std::string* error = nullptr); +bool isPackage(const std::string& path); + +// Exported for other programs, by the file's ending: +// .gltf (and a .bin and PNGs beside it), .glb (all in one): glTF 2.0, its own axes, metres; each +// visible object a node at its origin, turned and scaled into its points, one material each. +// .fbx (binary 7.4; pictures inside it and beside it): Y up, centimetres. +// .obj (and an .mtl and PNGs): Y up, metres. .ply: Z up, metres, with colours. +// .stl (binary): Z up, millimetres (for 3D printing), all objects as one. +// .3mf: for slicers (PrusaSlicer, Bambu Studio, Cura, OrcaSlicer): millimetres, each object apart. +// pngs: each object's picture as a PNG file's bytes (or empty: just its colour). +bool exportModel(const std::string& path, const Scene& scene, const std::vector>& pngs, std::string* error = nullptr); +// What exportModel can write: (ending, name) pairs. +const std::vector>& exportFormats(); + +} // namespace fry diff --git a/src/shapes.cpp b/src/shapes.cpp new file mode 100644 index 0000000..964fae6 --- /dev/null +++ b/src/shapes.cpp @@ -0,0 +1,359 @@ +#include "shapes.h" + +#include + +#include +#include +#include + +namespace fry { + +namespace { +constexpr float kPi = 3.14159265358979f; + +glm::mat4 at(glm::vec3 p, glm::vec3 size = glm::vec3(1.0f)) { return glm::scale(glm::translate(glm::mat4(1.0f), p), size); } + +// A box from (corner) lo to hi, as its own six faces. +void addBox(Mesh& m, glm::vec3 lo, glm::vec3 hi) { append(m, makeCube(1.0f), at((lo + hi) * 0.5f, hi - lo)); } + +// An outline drawn on the X–Z plane (x forward, z up), made `depth` thick across Y. +Mesh sideProfile(const std::vector& xz, float depth) { + std::vector pts; + for (glm::vec2 p : xz) pts.emplace_back(p.x, 0.0f, p.y); + return extrudeOutline(pts, glm::vec3(0, 1, 0), depth); +} + +// Grid box: a box cut along the given coordinates on each axis (points shared between sides). +Mesh gridBox(const std::vector (&c)[3]) { + Mesh m; + std::map, int> index; + auto point = [&](int ix, int iy, int iz) { + const std::array k{ix, iy, iz}; + auto [it, fresh] = index.try_emplace(k, int(m.verts.size())); + if (fresh) m.verts.emplace_back(c[0][size_t(ix)], c[1][size_t(iy)], c[2][size_t(iz)]); + return it->second; + }; + // Each side: its axis, which end, and the two axes across it (so their cross is outwards). + const int sides[6][4] = {{0, 1, 1, 2}, {0, 0, 2, 1}, {1, 1, 2, 0}, {1, 0, 0, 2}, {2, 1, 0, 1}, {2, 0, 1, 0}}; + for (const auto& s : sides) { + const int axis = s[0], u = s[2], v = s[3]; + const int fixed = s[1] ? int(c[axis].size()) - 1 : 0; + for (size_t i = 0; i + 1 < c[u].size(); ++i) + for (size_t j = 0; j + 1 < c[v].size(); ++j) { + auto P = [&](size_t a, size_t b) { + int idx[3]; + idx[axis] = fixed, idx[u] = int(a), idx[v] = int(b); + return point(idx[0], idx[1], idx[2]); + }; + m.faces.push_back({P(i, j), P(i + 1, j), P(i + 1, j + 1), P(i, j + 1)}); + } + } + return m; +} +} // namespace + +Mesh makeRoundedBox(glm::vec3 size, float radius, int segments) { + const glm::vec3 h = size * 0.5f; + radius = std::clamp(radius, 0.0f, std::min({h.x, h.y, h.z})); + segments = std::clamp(segments, 1, 16); + std::vector c[3]; + for (int a = 0; a < 3; ++a) { + // Across the rounded strip at each end: steps at even angles (the 45° line always there, + // where it turns from one side onto the next). + std::vector angles; + for (int k = 0; k <= segments; ++k) + if (float(k) * 90.0f / float(segments) < 45.0f - 1e-3f) angles.push_back(float(k) * 90.0f / float(segments)); + angles.push_back(45.0f); + std::vector lo; + for (auto it = angles.rbegin(); it != angles.rend(); ++it) lo.push_back(-h[a] + radius - radius * std::tan(glm::radians(*it))); + std::vector& v = c[a]; + for (float x : lo) v.push_back(x); + for (auto it = lo.rbegin(); it != lo.rend(); ++it) v.push_back(-*it); + std::sort(v.begin(), v.end()); + v.erase(std::unique(v.begin(), v.end(), [](float p, float q) { return std::abs(p - q) < 1e-6f; }), v.end()); + } + Mesh m = gridBox(c); + if (radius > 1e-6f) { + const glm::vec3 inner = h - glm::vec3(radius); + for (glm::vec3& p : m.verts) { + const glm::vec3 core = glm::clamp(p, -inner, inner); + const glm::vec3 d = p - core; + if (glm::length(d) > 1e-9f) p = core + glm::normalize(d) * radius; + } + } + mergeByDistance(m, 1e-6f); + return m; +} + +Mesh lathe(const std::vector& profile, int sides, bool closed) { + sides = std::max(sides, 3); + Mesh m; + std::vector> rings; + for (glm::vec2 p : profile) { + std::vector ring; + if (p.x < 1e-6f) { + ring.assign(size_t(sides), int(m.verts.size())); + m.verts.emplace_back(0.0f, 0.0f, p.y); + } else + for (int i = 0; i < sides; ++i) { + const float a = 2 * kPi * float(i) / float(sides); + ring.push_back(int(m.verts.size())); + m.verts.emplace_back(p.x * std::cos(a), p.x * std::sin(a), p.y); + } + rings.push_back(ring); + } + const size_t n = rings.size(); + for (size_t k = 0; k + (closed ? 0 : 1) < n; ++k) { + const auto &lo = rings[k], &hi = rings[(k + 1) % n]; + for (int i = 0; i < sides; ++i) { + const size_t a = size_t(i), b = size_t((i + 1) % sides); + std::vector f{lo[a], lo[b], hi[b], hi[a]}; + f.erase(std::unique(f.begin(), f.end()), f.end()); + while (f.size() > 1 && f.front() == f.back()) f.pop_back(); + if (f.size() >= 3) m.faces.push_back(f); + } + } + if (!closed) { + if (profile.front().x >= 1e-6f) m.faces.emplace_back(rings.front().rbegin(), rings.front().rend()); + if (profile.back().x >= 1e-6f) m.faces.push_back(rings.back()); + } + return m; +} + +namespace { +float S(const std::vector& s, size_t i) { return s[i]; } +int N(const std::vector& s, size_t i) { return int(std::lround(s[i])); } + +std::vector arc(glm::vec2 centre, float r, float from, float to, int steps) { + std::vector p; + for (int k = 0; k <= steps; ++k) { + const float a = glm::radians(from + (to - from) * float(k) / float(steps)); + p.push_back(centre + glm::vec2(std::cos(a), std::sin(a)) * r); + } + return p; +} + +const std::vector kRecipes = { + // ── Basic ── + {"cube", "Cube", false, false, {{"Width", 1, 0.001f, 100}, {"Depth", 1, 0.001f, 100}, {"Height", 1, 0.001f, 100}}, + [](const std::vector& s) { + Mesh m = makeCube(1.0f); + transform(m, at({0, 0, 0}, {S(s, 1), S(s, 0), S(s, 2)})); + return m; + }}, + {"rounded_cube", "Rounded cube", false, true, + {{"Width", 1, 0.001f, 100}, {"Depth", 1, 0.001f, 100}, {"Height", 1, 0.001f, 100}, {"Roundness", 0.1f, 0, 50}, {"Steps", 4, 1, 12, true}}, + [](const std::vector& s) { return makeRoundedBox({S(s, 1), S(s, 0), S(s, 2)}, S(s, 3), N(s, 4)); }}, + {"plane", "Plane", false, false, {{"Width", 2, 0.001f, 1000}, {"Depth", 2, 0.001f, 1000}, {"Cuts", 0, 0, 64, true}}, + [](const std::vector& s) { + Mesh m; + const int n = N(s, 2) + 1; + for (int j = 0; j <= n; ++j) + for (int i = 0; i <= n; ++i) m.verts.emplace_back((float(j) / float(n) - 0.5f) * S(s, 1), (float(i) / float(n) - 0.5f) * S(s, 0), 0.0f); + for (int j = 0; j < n; ++j) + for (int i = 0; i < n; ++i) { + const int a = j * (n + 1) + i; + m.faces.push_back({a, a + n + 1, a + n + 2, a + 1}); + } + return m; + }}, + {"circle", "Circle", false, false, {{"Sides", 32, 3, 256, true}, {"Radius", 0.5f, 0.001f, 100}}, + [](const std::vector& s) { + Mesh m; + std::vector f; + for (int i = 0; i < N(s, 0); ++i) { + const float a = 2 * kPi * float(i) / float(N(s, 0)); + f.push_back(int(m.verts.size())); + m.verts.emplace_back(std::cos(a) * S(s, 1), std::sin(a) * S(s, 1), 0.0f); + } + m.faces.push_back(f); + return m; + }}, + {"cylinder", "Cylinder", false, true, {{"Sides", 24, 3, 256, true}, {"Radius", 0.5f, 0.001f, 100}, {"Height", 1, 0.001f, 100}}, + [](const std::vector& s) { return makeCylinder(N(s, 0), S(s, 1), S(s, 2)); }}, + {"tube", "Tube (pipe)", false, true, + {{"Sides", 24, 3, 256, true}, {"Outside radius", 0.5f, 0.001f, 100}, {"Wall", 0.08f, 0.001f, 100}, {"Height", 1, 0.001f, 100}}, + [](const std::vector& s) { + const float r = S(s, 1), in = std::max(r - S(s, 2), 0.001f), h = S(s, 3); + return lathe({{r, -h / 2}, {r, h / 2}, {in, h / 2}, {in, -h / 2}}, N(s, 0), true); + }}, + {"cone", "Cone", false, true, + {{"Sides", 24, 3, 256, true}, {"Bottom radius", 0.5f, 0, 100}, {"Top radius", 0, 0, 100}, {"Height", 1, 0.001f, 100}}, + [](const std::vector& s) { return makeFrustum(N(s, 0), std::max(S(s, 1), 0.001f), S(s, 2), S(s, 3)); }}, + {"pyramid", "Pyramid", false, false, {{"Width", 1, 0.001f, 100}, {"Height", 1, 0.001f, 100}}, + [](const std::vector& s) { + Mesh m = makeCone(4, S(s, 0) * 0.70710678f, S(s, 1)); + transform(m, glm::rotate(glm::mat4(1.0f), kPi / 4, glm::vec3(0, 0, 1))); + return m; + }}, + {"sphere", "Sphere", false, true, {{"Segments", 24, 3, 256, true}, {"Rings", 12, 2, 128, true}, {"Radius", 0.5f, 0.001f, 100}}, + [](const std::vector& s) { return makeSphere(N(s, 0), N(s, 1), S(s, 2)); }}, + {"capsule", "Capsule", false, true, {{"Sides", 24, 3, 256, true}, {"Radius", 0.25f, 0.001f, 100}, {"Height", 1, 0.002f, 100}}, + [](const std::vector& s) { + const float r = std::min(S(s, 1), S(s, 2) / 2), h = S(s, 2); + std::vector p; + for (auto q : arc({0, -h / 2 + r}, r, -90, 0, 6)) p.push_back(q); + for (auto q : arc({0, h / 2 - r}, r, 0, 90, 6)) p.push_back(q); + for (auto& q : p) q.x = std::max(q.x, 0.0f); + return lathe(p, N(s, 0)); + }}, + {"torus", "Torus (ring)", false, true, + {{"Segments", 32, 3, 256, true}, {"Sides", 12, 3, 128, true}, {"Radius", 0.5f, 0.001f, 100}, {"Thickness", 0.15f, 0.001f, 100}}, + [](const std::vector& s) { return makeTorus(N(s, 0), N(s, 1), S(s, 2), S(s, 3)); }}, + {"wedge", "Wedge (ramp)", false, false, {{"Width", 1, 0.001f, 100}, {"Length", 1, 0.001f, 100}, {"Height", 0.5f, 0.001f, 100}}, + [](const std::vector& s) { + const float l = S(s, 1), h = S(s, 2); + return sideProfile({{-l / 2, 0}, {l / 2, 0}, {-l / 2, h}}, S(s, 0)); + }}, + {"stairs", "Stairs", false, false, + {{"Steps", 6, 1, 100, true}, {"Width", 1, 0.001f, 100}, {"Length", 1.6f, 0.001f, 100}, {"Height", 1.0f, 0.001f, 100}}, + [](const std::vector& s) { + const int n = N(s, 0); + const float L = S(s, 2), H = S(s, 3), d = L / float(n), h = H / float(n); + std::vector p{{0, 0}, {L, 0}, {L, H}}; + for (int k = n - 1; k >= 1; --k) p.push_back({float(k) * d, float(k + 1) * h}), p.push_back({float(k) * d, float(k) * h}); + p.push_back({0, h}); + for (auto& q : p) q.x -= L / 2; + return sideProfile(p, S(s, 1)); + }}, + {"arch", "Arch", false, false, + {{"Width", 1.2f, 0.01f, 100}, {"Height", 2.2f, 0.01f, 100}, {"Thickness", 0.15f, 0.001f, 50}, {"Depth", 0.3f, 0.001f, 100}, {"Steps", 12, 2, 128, true}}, + [](const std::vector& s) { + const float W = S(s, 0) / 2, H = S(s, 1), t = std::min(S(s, 2), W * 0.9f), r = W - t, spring = std::max(H - t - r, 0.0f); + std::vector p{{-W, 0}, {-W, H}, {W, H}, {W, 0}, {W - t, 0}}; + for (auto q : arc({0, spring}, r, 0, 180, N(s, 4))) p.push_back(q); + p.push_back({-W + t, 0}); + // (drawn across X; turned so the opening faces forward) + Mesh m = sideProfile(p, S(s, 3)); + transform(m, glm::rotate(glm::mat4(1.0f), kPi / 2, glm::vec3(0, 0, 1))); + return m; + }}, + // ── Starter models ── + {"table", "Table", true, false, + {{"Width", 1.2f, 0.05f, 20}, {"Depth", 0.7f, 0.05f, 20}, {"Height", 0.75f, 0.05f, 5}, {"Top thickness", 0.04f, 0.005f, 1}, {"Legs", 0.05f, 0.005f, 1}}, + [](const std::vector& s) { + const float w = S(s, 0) / 2, d = S(s, 1) / 2, h = S(s, 2), t = S(s, 3), l = S(s, 4), in = l; + Mesh m; + addBox(m, {-d, -w, h - t}, {d, w, h}); + for (int i = 0; i < 4; ++i) { + const float x = (i & 1 ? 1 : -1) * (d - in - l / 2), y = (i & 2 ? 1 : -1) * (w - in - l / 2); + addBox(m, {x - l / 2, y - l / 2, 0}, {x + l / 2, y + l / 2, h - t}); + } + return m; + }}, + {"chair", "Chair", true, false, + {{"Width", 0.45f, 0.05f, 5}, {"Depth", 0.45f, 0.05f, 5}, {"Seat height", 0.46f, 0.05f, 3}, {"Back height", 0.45f, 0, 3}, {"Legs", 0.04f, 0.005f, 1}}, + [](const std::vector& s) { + const float w = S(s, 0) / 2, d = S(s, 1) / 2, h = S(s, 2), b = S(s, 3), l = S(s, 4), t = 0.04f; + Mesh m; + addBox(m, {-d, -w, h - t}, {d, w, h}); + for (int i = 0; i < 4; ++i) { + const float x = (i & 1 ? 1 : -1) * (d - l / 2), y = (i & 2 ? 1 : -1) * (w - l / 2); + addBox(m, {x - l / 2, y - l / 2, 0}, {x + l / 2, y + l / 2, h - t}); + } + if (b > 0) { // the back, at the chair's back (−X): two posts and a board + for (int i = 0; i < 2; ++i) { + const float y = (i ? 1 : -1) * (w - l / 2); + addBox(m, {-d, y - l / 2, h}, {-d + l, y + l / 2, h + b}); + } + addBox(m, {-d, -w, h + b * 0.55f}, {-d + l * 0.8f, w, h + b}); + } + return m; + }}, + {"shelf", "Shelf", true, false, + {{"Width", 0.8f, 0.05f, 20}, {"Depth", 0.3f, 0.05f, 5}, {"Height", 1.8f, 0.05f, 10}, {"Shelves", 5, 2, 30, true}, {"Board", 0.02f, 0.003f, 0.5f}}, + [](const std::vector& s) { + const float w = S(s, 0) / 2, d = S(s, 1) / 2, h = S(s, 2), t = S(s, 4); + const int n = N(s, 3); + Mesh m; + addBox(m, {-d, -w, 0}, {d, -w + t, h}); + addBox(m, {-d, w - t, 0}, {d, w, h}); + addBox(m, {-d, -w + t, 0}, {-d + t, w - t, h}); // the back + for (int k = 0; k < n; ++k) { + const float z = (h - t) * float(k) / float(n - 1); + addBox(m, {-d + t, -w + t, z}, {d, w - t, z + t}); + } + return m; + }}, + {"barrel", "Barrel", true, true, {{"Sides", 24, 6, 128, true}, {"Radius", 0.3f, 0.01f, 10}, {"Height", 0.9f, 0.01f, 10}, {"Bulge", 0.05f, 0, 1}}, + [](const std::vector& s) { + std::vector p; + const float r = S(s, 1), h = S(s, 2); + p.push_back({0, 0}); + for (int k = 0; k <= 8; ++k) { + const float t = float(k) / 8.0f; + p.push_back({r + S(s, 3) * std::sin(t * kPi), t * h}); + } + p.push_back({0, h}); + Mesh m = lathe(p, N(s, 0)); + transform(m, at({0, 0, -h / 2})); + return m; + }}, + {"bottle", "Bottle", true, true, {{"Sides", 24, 6, 128, true}, {"Radius", 0.04f, 0.005f, 2}, {"Height", 0.3f, 0.02f, 5}, {"Neck", 0.013f, 0.002f, 1}}, + [](const std::vector& s) { + const float r = S(s, 1), h = S(s, 2), n = std::min(S(s, 3), r); + std::vector p{{0, 0}, {r * 0.92f, 0}, {r, h * 0.03f}, {r, h * 0.6f}, {r * 0.75f, h * 0.7f}, {n, h * 0.8f}, {n, h * 0.97f}, + {n * 1.2f, h * 0.975f}, {n * 1.2f, h}, {0, h}}; + Mesh m = lathe(p, N(s, 0)); + transform(m, at({0, 0, -h / 2})); + return m; + }}, + {"pillar", "Pillar", true, true, {{"Sides", 16, 4, 128, true}, {"Radius", 0.2f, 0.01f, 10}, {"Height", 3, 0.1f, 50}}, + [](const std::vector& s) { + const float r = S(s, 1), h = S(s, 2), slab = r * 0.5f; + Mesh m; + addBox(m, {-r * 1.4f, -r * 1.4f, 0}, {r * 1.4f, r * 1.4f, slab}); + addBox(m, {-r * 1.4f, -r * 1.4f, h - slab}, {r * 1.4f, r * 1.4f, h}); + append(m, makeCylinder(N(s, 0), r, h - 2 * slab), at({0, 0, h / 2})); + return m; + }}, + {"door_frame", "Door frame", true, false, + {{"Width", 0.9f, 0.1f, 10}, {"Height", 2.1f, 0.1f, 10}, {"Frame", 0.08f, 0.005f, 1}, {"Depth", 0.15f, 0.005f, 2}}, + [](const std::vector& s) { + const float W = S(s, 0) / 2 + S(s, 2), H = S(s, 1) + S(s, 2), t = S(s, 2); + Mesh m = sideProfile({{-W, 0}, {-W, H}, {W, H}, {W, 0}, {W - t, 0}, {W - t, H - t}, {-W + t, H - t}, {-W + t, 0}}, S(s, 3)); + transform(m, glm::rotate(glm::mat4(1.0f), kPi / 2, glm::vec3(0, 0, 1))); + return m; + }}, + {"crate", "Crate", true, false, {{"Size", 0.6f, 0.05f, 10}, {"Frame", 0.06f, 0.005f, 1}}, + [](const std::vector& s) { + const float h = S(s, 0) / 2, f = std::min(S(s, 1), h * 0.45f); + Mesh m = makeCube(S(s, 0)); + std::set all; + for (int i = 0; i < 6; ++i) all.insert(i); + insetFaces(m, all, 1.0f - (h - f) / h); // a frame round each side, the panels set in + extrudeFaces(m, {0}, -f * 0.3f); + for (int i = 1; i < 6; ++i) extrudeFaces(m, {i}, -f * 0.3f); + return m; + }}, +}; +} // namespace + +const std::vector& shapeRecipes() { return kRecipes; } + +const ShapeRecipe* findRecipe(std::string_view id) { + for (const ShapeRecipe& r : kRecipes) + if (id == r.id) return &r; + return nullptr; +} + +std::vector defaultSettings(const ShapeRecipe& r) { + std::vector s; + for (const ShapeSetting& p : r.settings) s.push_back(p.value); + return s; +} + +Mesh makeFromRecipe(const ShapeRecipe& r, std::vector s) { + for (size_t i = 0; i < r.settings.size(); ++i) { + if (i >= s.size()) s.push_back(r.settings[i].value); + s[i] = std::clamp(s[i], r.settings[i].min, r.settings[i].max); + if (r.settings[i].whole) s[i] = std::round(s[i]); + } + Mesh m = r.make(s); + // Standing on the ground at the origin. + const glm::vec3 lo = boundsMin(m), hi = boundsMax(m); + transform(m, at({-(lo.x + hi.x) / 2, -(lo.y + hi.y) / 2, -lo.z})); + return m; +} + +} // namespace fry diff --git a/src/shapes.h b/src/shapes.h new file mode 100644 index 0000000..cd7cb5c --- /dev/null +++ b/src/shapes.h @@ -0,0 +1,41 @@ +#pragma once +// Shapes to start from, with settings you can change after adding one (until you edit its +// shape by hand): the basic ones, and starter models (a table, a chair...). All stand on the +// ground at their origin (z = 0 is their bottom), centred on it the other ways. +#include "mesh.h" + +#include +#include +#include + +namespace fry { + +struct ShapeSetting { + const char* name; + float value, min, max; + bool whole = false; // a count (sides, steps...) +}; + +struct ShapeRecipe { + const char* id; // as saved + const char* name; // as shown + bool starter; // a starter model (else a basic shape) + bool smooth; // looks best smooth-shaded + std::vector settings; + Mesh (*make)(const std::vector& s); +}; + +const std::vector& shapeRecipes(); +const ShapeRecipe* findRecipe(std::string_view id); +// The recipe's mesh for these settings (missing ones take their defaults). +Mesh makeFromRecipe(const ShapeRecipe& r, std::vector settings); +std::vector defaultSettings(const ShapeRecipe& r); + +// A box with rounded edges and corners (`radius`, in `segments` steps round each). +Mesh makeRoundedBox(glm::vec3 size, float radius, int segments); +// A shape turned round the Z axis: `profile` is (distance out, height) from the bottom up. +// Points at distance 0 become a single point; open ends get a lid; `closed` joins the last +// profile point back to the first (a ring, like a tube's cross-section). +Mesh lathe(const std::vector& profile, int sides, bool closed = false); + +} // namespace fry diff --git a/src/sticker.cpp b/src/sticker.cpp new file mode 100644 index 0000000..c4a0865 --- /dev/null +++ b/src/sticker.cpp @@ -0,0 +1,202 @@ +// Pictures on top of the painting: stickers that wrap over edges, and pictures pinned by corners. +#include "scene.h" + +#include + +#include +#include +#include +#include + +namespace fry { + +int stickerFace(const Mesh& m, const PictureLayer& l) { + if (l.face >= 0 && l.face < int(m.faces.size())) return l.face; + int best = -1; + float d = 1e30f; + for (size_t f = 0; f < m.faces.size(); ++f) + if (const float e = glm::distance(faceCenter(m, int(f)), l.centre); e < d) d = e, best = int(f); + return best; +} + +bool stickerFrame(const Mesh& m, const PictureLayer& l, glm::vec3& right, glm::vec3& up, glm::vec3& normal) { + const int f = stickerFace(m, l); + if (f < 0) return false; + normal = faceNormal(m, f); + up = l.up - normal * glm::dot(l.up, normal); + if (glm::length(up) < 1e-6f) up = glm::cross(normal, std::abs(normal.x) < 0.9f ? glm::vec3(1, 0, 0) : glm::vec3(0, 1, 0)); + up = glm::normalize(up); + right = glm::cross(up, normal); + return true; +} + +void stampSticker(Image& dst, const Mesh& m, const std::vector>& uvs, const PictureLayer& l, std::vector* owner, uint8_t id) { + if (!l.image || l.image->empty() || uvs.size() != m.faces.size() || l.width <= 0 || l.height <= 0) return; + glm::vec3 right, up, n; + if (!stickerFrame(m, l, right, up, n)) return; + const int root = stickerFace(m, l); + const glm::vec3 c = l.centre - n * glm::dot(l.centre - faceCenter(m, root), n); // (on its face's plane) + const float hw = l.width * 0.5f, hh = l.height * 0.5f; + const Image& src = *l.image; + const glm::vec2 size(float(dst.w), float(dst.h)); + std::map, std::vector> byEdge; + for (size_t f = 0; f < m.faces.size(); ++f) { + const auto& fv = m.faces[f]; + for (size_t i = 0; i < fv.size(); ++i) byEdge[{std::min(fv[i], fv[(i + 1) % fv.size()]), std::max(fv[i], fv[(i + 1) % fv.size()])}].push_back(int(f)); + } + std::vector seen(m.faces.size(), 0), done(size_t(dst.w) * size_t(dst.h), 0); + // Each face reached: how to fold it flat into the sticker's plane (turned about the edges + // between it and the sticker's face, as a sticker bends over them). Nearest the sticker's + // middle first, so a face round a corner gets the part of it that reaches it straight, not + // what's left after going the long way round over the faces beside it. + struct Todo { + float near; + int face; + glm::mat4 fold; + bool operator<(const Todo& o) const { return near > o.near; } + }; + std::priority_queue todo; + todo.push({0.0f, root, glm::mat4(1.0f)}); + while (!todo.empty()) { + const Todo next = todo.top(); + todo.pop(); + const int f = next.face; + const glm::mat4 fold = next.fold; + if (seen[size_t(f)]) continue; + seen[size_t(f)] = 1; + const auto& fv = m.faces[size_t(f)]; + auto flat = [&](const glm::vec3& p) { + const glm::vec3 q = glm::vec3(fold * glm::vec4(p, 1.0f)) - c; + return glm::vec2(glm::dot(q, right), glm::dot(q, up)); + }; + glm::vec2 lo(1e30f), hi(-1e30f); + for (int v : fv) { + const glm::vec2 q = flat(m.verts[size_t(v)]); + lo = glm::min(lo, q), hi = glm::max(hi, q); + } + if (hi.x < -hw || lo.x > hw || hi.y < -hh || lo.y > hh) continue; // (the sticker doesn't reach it, nor past it) + for (const auto& t : triangulate(m, f)) { + glm::vec3 w[3]; + glm::vec2 px[3]; + for (int k = 0; k < 3; ++k) { + w[k] = m.verts[size_t(t[size_t(k)])]; + px[k] = uvs[size_t(f)][size_t(std::find(fv.begin(), fv.end(), t[size_t(k)]) - fv.begin())] * size; + } + const float twice = (px[1].x - px[0].x) * (px[2].y - px[0].y) - (px[2].x - px[0].x) * (px[1].y - px[0].y); + if (std::abs(twice) < 1e-9f) continue; + const glm::vec2 plo = glm::max(glm::min(px[0], glm::min(px[1], px[2])) - 2.0f, glm::vec2(0.0f)); + const glm::vec2 phi = glm::min(glm::max(px[0], glm::max(px[1], px[2])) + 2.0f, size - 1.0f); + for (int y = int(plo.y); y <= int(phi.y); ++y) + for (int x = int(plo.x); x <= int(phi.x); ++x) { + char& d = done[size_t(y) * size_t(dst.w) + size_t(x)]; + if (d) continue; + const glm::vec2 q(float(x) + 0.5f, float(y) + 0.5f); + float b[3]; + bool in = true; + for (int k = 0; k < 3; ++k) { + const glm::vec2 a = px[(k + 1) % 3], e = px[(k + 2) % 3]; + b[k] = ((e.x - a.x) * (q.y - a.y) - (q.x - a.x) * (e.y - a.y)) / twice; + in &= b[k] * std::abs(twice) / std::max(glm::length(e - a), 1e-6f) >= -1.5f; // (and a little past: no seams) + } + if (!in) continue; + const glm::vec2 s = flat(w[0] * b[0] + w[1] * b[1] + w[2] * b[2]); + const glm::vec2 uv(s.x / l.width + 0.5f, 0.5f - s.y / l.height); + if (uv.x < 0 || uv.y < 0 || uv.x > 1 || uv.y > 1) continue; + d = 1; + const glm::vec4 col = sample(src, uv * glm::vec2(src.w, src.h)); + const float a = col.a * l.opacity; + if (owner && a > 0.05f) (*owner)[size_t(y) * size_t(dst.w) + size_t(x)] = id; + uint8_t* p = dst.at(x, y); + for (int k = 0; k < 3; ++k) p[k] = uint8_t(std::lround(float(p[k]) + (col[k] * 255.0f - float(p[k])) * a)); + } + } + // On to the faces next to it, folded about the edge between. + const glm::vec3 nf = faceNormal(m, f); + for (size_t i = 0; i < fv.size(); ++i) { + const int a = fv[i], b = fv[(i + 1) % fv.size()]; + // (how near the sticker's middle the edge comes, flat) + const glm::vec2 ea = flat(m.verts[size_t(a)]), eb = flat(m.verts[size_t(b)]); + const float along = glm::clamp(glm::dot(-ea, eb - ea) / std::max(glm::dot(eb - ea, eb - ea), 1e-12f), 0.0f, 1.0f); + const float near = std::max(next.near, glm::length(ea + (eb - ea) * along)); + for (int g : byEdge[{std::min(a, b), std::max(a, b)}]) { + if (seen[size_t(g)]) continue; + const glm::vec3 ng = faceNormal(m, g), pa = m.verts[size_t(a)], e = glm::normalize(m.verts[size_t(b)] - pa); + const float angle = std::atan2(glm::dot(glm::cross(ng, nf), e), glm::dot(ng, nf)); + todo.push({near, g, fold * glm::translate(glm::mat4(1.0f), pa) * glm::rotate(glm::mat4(1.0f), angle, e) * glm::translate(glm::mat4(1.0f), -pa)}); + } + } + } +} + +void settleSticker(const Mesh& m, PictureLayer& l) { + glm::vec3 right, up, n; + if (!stickerFrame(m, l, right, up, n)) return; + const int root = stickerFace(m, l); + const glm::vec3 c = l.centre - n * glm::dot(l.centre - faceCenter(m, root), n); + std::map, std::vector> byEdge; + for (size_t f = 0; f < m.faces.size(); ++f) { + const auto& fv = m.faces[f]; + for (size_t i = 0; i < fv.size(); ++i) byEdge[{std::min(fv[i], fv[(i + 1) % fv.size()]), std::max(fv[i], fv[(i + 1) % fv.size()])}].push_back(int(f)); + } + // Unfolded nearest-first (as stampSticker does) until the face its middle lies on. + struct Todo { + float near; + int face; + glm::mat4 fold; + bool operator<(const Todo& o) const { return near > o.near; } + }; + std::priority_queue todo; + todo.push({0.0f, root, glm::mat4(1.0f)}); + std::vector seen(m.faces.size(), 0); + const float reach = std::max(l.width, l.height); + while (!todo.empty()) { + const Todo next = todo.top(); + todo.pop(); + if (seen[size_t(next.face)] || next.near > reach) continue; + seen[size_t(next.face)] = 1; + const auto& fv = m.faces[size_t(next.face)]; + std::vector poly; + for (int v : fv) { + const glm::vec3 q = glm::vec3(next.fold * glm::vec4(m.verts[size_t(v)], 1.0f)) - c; + poly.push_back({glm::dot(q, right), glm::dot(q, up)}); + } + bool inside = false; // (the middle, 0 0, in the face as unfolded) + for (size_t i = 0, j = poly.size() - 1; i < poly.size(); j = i++) + if ((poly[i].y > 0) != (poly[j].y > 0) && 0 < (poly[j].x - poly[i].x) * (0 - poly[i].y) / (poly[j].y - poly[i].y) + poly[i].x) inside = !inside; + if (inside) { + if (next.face == root) return; + const glm::mat4 back = glm::inverse(next.fold); // (the sticker's plane folded onto that face) + l.face = next.face; + l.centre = glm::vec3(back * glm::vec4(c, 1.0f)); + l.up = glm::normalize(glm::vec3(back * glm::vec4(up, 0.0f))); + return; + } + const glm::vec3 nf = faceNormal(m, next.face); + for (size_t i = 0; i < fv.size(); ++i) { + const int a = fv[i], b = fv[(i + 1) % fv.size()]; + const glm::vec2 ea = poly[i], eb = poly[(i + 1) % fv.size()]; + const float along = glm::clamp(glm::dot(-ea, eb - ea) / std::max(glm::dot(eb - ea, eb - ea), 1e-12f), 0.0f, 1.0f); + const float near = std::max(next.near, glm::length(ea + (eb - ea) * along)); + for (int g : byEdge[{std::min(a, b), std::max(a, b)}]) { + if (seen[size_t(g)]) continue; + const glm::vec3 ng = faceNormal(m, g), pa = m.verts[size_t(a)], e = glm::normalize(m.verts[size_t(b)] - pa); + const float angle = std::atan2(glm::dot(glm::cross(ng, nf), e), glm::dot(ng, nf)); + todo.push({near, g, next.fold * glm::translate(glm::mat4(1.0f), pa) * glm::rotate(glm::mat4(1.0f), angle, e) * glm::translate(glm::mat4(1.0f), -pa)}); + } + } + } +} + +Image composePicture(const Object& o, std::vector* owner) { + Image img = *o.picture; + if (owner) owner->assign(size_t(img.w) * size_t(img.h), 0); + for (size_t i = 0; i < o.layers.size() && i < 255; ++i) { + const PictureLayer& l = o.layers[i]; + if (!l.visible || !l.image) continue; + if (l.sticker) stampSticker(img, o.mesh, o.uvs, l, owner, uint8_t(i + 1)); + else stampQuad(img, *l.image, l.corners, l.opacity, owner, uint8_t(i + 1)); + } + return img; +} + +} // namespace fry diff --git a/src/tools.cpp b/src/tools.cpp new file mode 100644 index 0000000..b195908 --- /dev/null +++ b/src/tools.cpp @@ -0,0 +1,491 @@ +// The bigger mesh tools: rounding edges, smoothing, loop cuts, and finding loops and rings. +#include "mesh.h" + +#include +#include +#include + +namespace fry { + +namespace { +using Edge = std::pair; +Edge key(int a, int b) { return a < b ? Edge{a, b} : Edge{b, a}; } + +void tidy(std::vector& f) { + f.erase(std::unique(f.begin(), f.end()), f.end()); + while (f.size() > 1 && f.front() == f.back()) f.pop_back(); +} + +// Each edge as its faces run along it: (from, to) → (face, the corner it starts at). +std::map> directed(const Mesh& m) { + std::map> d; + for (size_t f = 0; f < m.faces.size(); ++f) { + const auto& fv = m.faces[f]; + for (size_t i = 0; i < fv.size(); ++i) d[{fv[i], fv[(i + 1) % fv.size()]}] = {int(f), int(i)}; + } + return d; +} + +glm::vec3 normalOf(const std::vector& v, const std::vector& f) { // (Newell's) + glm::vec3 n(0.0f); + for (size_t i = 0; i < f.size(); ++i) { + const glm::vec3 a = v[size_t(f[i])], b = v[size_t(f[(i + 1) % f.size()])]; + n += glm::vec3((a.y - b.y) * (a.z + b.z), (a.z - b.z) * (a.x + b.x), (a.x - b.x) * (a.y + b.y)); + } + return glm::length(n) > 1e-20f ? glm::normalize(n) : n; +} + +std::map> neighbours(const Mesh& m) { + std::map> n; + for (const auto& f : m.faces) + for (size_t i = 0; i < f.size(); ++i) { + const int a = f[i], b = f[(i + 1) % f.size()]; + n[a].insert(b), n[b].insert(a); + } + return n; +} +} // namespace + +std::set> sharpEdges(const Mesh& m, float angle) { + const auto d = directed(m); + const float c = std::cos(glm::radians(angle)); + std::set out; + for (const auto& [e, fc] : d) { + if (e.first > e.second) continue; + auto twin = d.find({e.second, e.first}); + if (twin != d.end() && glm::dot(faceNormal(m, fc.first), faceNormal(m, twin->second.first)) < c) out.insert(e); + } + return out; +} + +// ── Bevel ── +// +// Every face corner at a point of a rounded edge is replaced: where both its edges are rounded, +// by a point inside the face `width` from both; where one is, by a point slid along the other +// (shared with the face across it); where neither, by the point itself plus any slid points on +// its edges. Each rounded edge then gets a strip between its two faces' new edges, curved along a +// circle; whatever holes are left round a point (the corners where several strips meet) are +// filled, as part of a sphere when they're rounded corners, flat otherwise. +void bevelEdges(Mesh& m, const std::set>& want, float width, int segments) { + if (width <= 0.0f) return; + segments = std::clamp(segments, 1, 16); + const auto d = directed(m); + std::set bev; + for (auto [a, b] : want) + if (d.count({a, b}) && d.count({b, a})) bev.insert(key(a, b)); + if (bev.empty()) return; + auto isBev = [&](int a, int b) { return bev.count(key(a, b)) > 0; }; + std::set at; + for (auto [a, b] : bev) at.insert(a), at.insert(b); + const int nOrig = int(m.verts.size()); + const std::vector orig = m.verts; + std::map owner; // new point → the point it came from + auto add = [&](glm::vec3 p, int from) { + owner[int(m.verts.size())] = from; + m.verts.push_back(p); + return int(m.verts.size()) - 1; + }; + auto dirTo = [&](int v, int w) { + const glm::vec3 x = orig[size_t(w)] - orig[size_t(v)]; + const float l = glm::length(x); + return l > 1e-12f ? x / l : glm::vec3(0.0f); + }; + auto sinOf = [](glm::vec3 a, glm::vec3 b) { return std::max(glm::length(glm::cross(a, b)), 0.05f); }; + + // Points slid along edges, shared by the faces either side. + std::map> slideSum; // (point, towards) → sum, count + for (const auto& fv : m.faces) + for (size_t i = 0; i < fv.size(); ++i) { + const int v = fv[i], prev = fv[(i + fv.size() - 1) % fv.size()], next = fv[(i + 1) % fv.size()]; + if (!at.count(v)) continue; + const bool in = isBev(prev, v), out = isBev(v, next); + if (in == out) continue; + const glm::vec3 a = dirTo(v, prev), b = dirTo(v, next); + const int along = in ? next : prev; + const float t = std::min(width / sinOf(a, b), glm::distance(orig[size_t(v)], orig[size_t(along)]) * 0.49f); + auto& s = slideSum[{v, along}]; + s.first += orig[size_t(v)] + (in ? b : a) * t, ++s.second; + } + std::map slide; + for (const auto& [k, s] : slideSum) slide[k] = add(s.first / float(s.second), k.first); + + // What each face corner becomes. + std::vector>> rep(m.faces.size()); + for (size_t f = 0; f < m.faces.size(); ++f) { + const auto& fv = m.faces[f]; + rep[f].resize(fv.size()); + for (size_t i = 0; i < fv.size(); ++i) { + const int v = fv[i], prev = fv[(i + fv.size() - 1) % fv.size()], next = fv[(i + 1) % fv.size()]; + auto& r = rep[f][i]; + if (!at.count(v)) { + r = {v}; + continue; + } + const bool in = isBev(prev, v), out = isBev(v, next); + if (in && out) { + const glm::vec3 a = dirTo(v, prev), b = dirTo(v, next); + const float most = std::min(glm::distance(orig[size_t(v)], orig[size_t(prev)]), glm::distance(orig[size_t(v)], orig[size_t(next)])) * 0.49f; + glm::vec3 off = (a + b) * (width / sinOf(a, b)); + if (glm::length(off) > most * 1.42f) off *= most * 1.42f / glm::length(off); + r = {add(orig[size_t(v)] + off, v)}; + } else if (in) r = {slide[{v, next}]}; + else if (out) r = {slide[{v, prev}]}; + else { + const auto sp = slide.find({v, prev}), sn = slide.find({v, next}); + if (sp != slide.end()) r.push_back(sp->second); + if (sp == slide.end() || sn == slide.end()) r.push_back(v); + if (sn != slide.end()) r.push_back(sn->second); + } + } + } + std::vector> faces; + for (size_t f = 0; f < m.faces.size(); ++f) { + std::vector nf; + for (const auto& r : rep[f]) nf.insert(nf.end(), r.begin(), r.end()); + faces.push_back(std::move(nf)); + } + + // The strips, curved: a conic from one side to the other, its middle towards where the old + // edge was (a true circle when both sides are the same distance from it). + struct Round { + glm::vec3 centre; + float radius; + }; + std::map> rounds; // by original point + std::map> arcs; + auto arc = [&](int p, int q, int v, glm::vec3 dir) { + if (p == q) return std::vector(size_t(segments + 1), p); + if (auto it = arcs.find({p, q}); it != arcs.end()) return it->second; + if (auto it = arcs.find({q, p}); it != arcs.end()) return std::vector(it->second.rbegin(), it->second.rend()); + const glm::vec3 A = m.verts[size_t(p)], B = m.verts[size_t(q)], V = orig[size_t(v)]; + const glm::vec3 C = V + dir * std::max(glm::dot(A - V, dir), glm::dot(B - V, dir)); + const glm::vec3 la = A - C, lb = B - C; + const float lenA = glm::length(la), lenB = glm::length(lb); + const float cosPhi = lenA > 1e-9f && lenB > 1e-9f ? std::clamp(glm::dot(la, lb) / (lenA * lenB), -1.0f, 1.0f) : -1.0f; + const float phi = std::acos(cosPhi), w = std::sin(phi * 0.5f); + std::vector pts{p}; + for (int k = 1; k < segments; ++k) { + const float t = float(k) / float(segments), b0 = (1 - t) * (1 - t), b1 = 2 * t * (1 - t) * w, b2 = t * t; + pts.push_back(add((A * b0 + C * b1 + B * b2) / (b0 + b1 + b2), v)); + } + pts.push_back(q); + if (segments > 1 && 1.0f + cosPhi > 1e-3f) rounds[v].push_back({C + (la + lb) / (1.0f + cosPhi), (lenA + lenB) * 0.5f * std::tan(phi * 0.5f)}); + arcs[{p, q}] = pts; + return pts; + }; + for (auto [a, b] : bev) { + const auto [f, ia] = d.at({a, b}); + const auto [g, ib] = d.at({b, a}); + const size_t nf = rep[size_t(f)].size(), ng = rep[size_t(g)].size(); + const int Aa = rep[size_t(f)][size_t(ia)].back(), Ab = rep[size_t(f)][(size_t(ia) + 1) % nf].front(); + const int Bb = rep[size_t(g)][size_t(ib)].back(), Ba = rep[size_t(g)][(size_t(ib) + 1) % ng].front(); + const glm::vec3 dir = dirTo(a, b); + const auto atA = arc(Aa, Ba, a, dir), atB = arc(Ab, Bb, b, -dir); + for (int k = 0; k < segments; ++k) faces.push_back({atB[size_t(k)], atA[size_t(k)], atA[size_t(k) + 1], atB[size_t(k) + 1]}); + } + for (auto& f : faces) tidy(f); + std::erase_if(faces, [](const std::vector& f) { return f.size() < 3; }); + + // Fill the holes round the points. + auto relevant = [&](int x) { return x >= nOrig || at.count(x); }; + std::set has; + for (const auto& f : faces) + for (size_t i = 0; i < f.size(); ++i) has.insert({f[i], f[(i + 1) % f.size()]}); + std::map nextOf; + std::set unclear; + for (auto [x, y] : has) + if (!has.count({y, x}) && relevant(x) && relevant(y)) { + if (nextOf.count(y)) unclear.insert(y); + nextOf[y] = x; + } + std::set used; + std::map faceOfEdge; + for (size_t f = 0; f < faces.size(); ++f) + for (size_t i = 0; i < faces[f].size(); ++i) faceOfEdge[{faces[f][i], faces[f][(i + 1) % faces[f].size()]}] = int(f); + std::set mergedInto; + for (const auto& [start, unused] : nextOf) { + if (used.count(start) || unclear.count(start)) continue; + std::vector loop{start}; + bool ok = false; + for (int x = nextOf[start], n = 0; n < 4096; ++n) { + if (x == start) { + ok = true; + break; + } + if (!nextOf.count(x) || unclear.count(x) || used.count(x)) break; + loop.push_back(x); + x = nextOf[x]; + } + if (!ok || loop.size() < 3) continue; + used.insert(loop.begin(), loop.end()); + const bool hasOrig = std::any_of(loop.begin(), loop.end(), [&](int x) { return x < nOrig; }); + const int v = hasOrig ? -1 : owner[loop[0]]; + if (!hasOrig && segments > 1 && rounds.count(v)) { // a rounded corner: part of a sphere + glm::vec3 O(0.0f), c(0.0f); + float r = 0.0f; + for (const Round& rd : rounds[v]) O += rd.centre, r += rd.radius; + O /= float(rounds[v].size()), r /= float(rounds[v].size()); + for (int x : loop) c += m.verts[size_t(x)]; + c /= float(loop.size()); + auto onBall = [&](glm::vec3 p) { return glm::length(p - O) > 1e-9f ? O + glm::normalize(p - O) * r : p; }; + const int rings = std::max(1, segments / 2); + std::vector prev = loop; + for (int j = 1; j < rings; ++j) { + std::vector ring; + for (int x : loop) ring.push_back(add(onBall(glm::mix(m.verts[size_t(x)], c, float(j) / float(rings))), v)); + for (size_t i = 0; i < loop.size(); ++i) faces.push_back({prev[i], prev[(i + 1) % loop.size()], ring[(i + 1) % loop.size()], ring[i]}); + prev = ring; + } + const int mid = add(onBall(c), v); + for (size_t i = 0; i < loop.size(); ++i) faces.push_back({prev[i], prev[(i + 1) % loop.size()], mid}); + continue; + } + // Flat: joined into the face beside it when that's flat with it (one face, not two). + const glm::vec3 n = normalOf(m.verts, loop); + bool merged = false; + for (size_t i = 0; i < loop.size() && !merged; ++i) { + const int x = loop[i], y = loop[(i + 1) % loop.size()]; + auto it = faceOfEdge.find({y, x}); + if (it == faceOfEdge.end() || mergedInto.count(it->second)) continue; + auto& g = faces[size_t(it->second)]; + int shared = 0; + for (size_t k = 0; k < loop.size(); ++k) shared += faceOfEdge.count({loop[(k + 1) % loop.size()], loop[k]}) && faceOfEdge[{loop[(k + 1) % loop.size()], loop[k]}] == it->second; + if (shared != 1 || glm::dot(normalOf(m.verts, g), n) < 0.999f) continue; + // g runs y → x; put the loop's way from y round to x between them. + std::vector path; + for (size_t k = 1; k + 1 < loop.size(); ++k) path.push_back(loop[(i + 1 + k) % loop.size()]); + const auto pos = size_t(std::find(g.begin(), g.end(), y) - g.begin()); + g.insert(g.begin() + std::ptrdiff_t(pos + 1), path.begin(), path.end()); + mergedInto.insert(it->second); + merged = true; + } + if (!merged) faces.push_back(loop); + } + m.faces = std::move(faces); + removeUnused(m); +} + +// ── Subdivide (Catmull–Clark) ── + +void subdivide(Mesh& m, bool smooth, float keepAngle) { + if (m.faces.empty()) return; + const auto d = directed(m); + const std::set sharp = keepAngle < 179.9f ? sharpEdges(m, keepAngle) : std::set{}; + auto crease = [&](int a, int b) { return !d.count({a, b}) || !d.count({b, a}) || sharp.count(key(a, b)); }; + Mesh out; + out.verts = m.verts; + std::vector facePt(m.faces.size()); + for (size_t f = 0; f < m.faces.size(); ++f) facePt[f] = int(out.verts.size()), out.verts.push_back(faceCenter(m, int(f))); + std::map edgePt; + for (size_t f = 0; f < m.faces.size(); ++f) { + const auto& fv = m.faces[f]; + for (size_t i = 0; i < fv.size(); ++i) { + const int a = fv[i], b = fv[(i + 1) % fv.size()]; + if (edgePt.count(key(a, b))) continue; + glm::vec3 p = (m.verts[size_t(a)] + m.verts[size_t(b)]) * 0.5f; + if (smooth && !crease(a, b)) + p = (m.verts[size_t(a)] + m.verts[size_t(b)] + out.verts[size_t(facePt[size_t(d.at({a, b}).first)])] + + out.verts[size_t(facePt[size_t(d.at({b, a}).first)])]) * + 0.25f; + edgePt[key(a, b)] = int(out.verts.size()); + out.verts.push_back(p); + } + } + if (smooth) { + std::vector faceSum(m.verts.size(), glm::vec3(0.0f)); + std::vector faceCount(m.verts.size(), 0); + for (size_t f = 0; f < m.faces.size(); ++f) + for (int v : m.faces[f]) faceSum[size_t(v)] += out.verts[size_t(facePt[f])], ++faceCount[size_t(v)]; + for (const auto& [v, nb] : neighbours(m)) { + const glm::vec3 P = m.verts[size_t(v)]; + std::vector creases; + glm::vec3 mid(0.0f); + for (int w : nb) { + mid += (P + m.verts[size_t(w)]) * 0.5f; + if (crease(v, w)) creases.push_back(w); + } + const float n = float(nb.size()); + if (creases.size() >= 3) continue; // a corner: stays + if (creases.size() == 2) out.verts[size_t(v)] = P * 0.75f + (m.verts[size_t(creases[0])] + m.verts[size_t(creases[1])]) * 0.125f; + else if (faceCount[size_t(v)] > 0 && n >= 3) + out.verts[size_t(v)] = (faceSum[size_t(v)] / float(faceCount[size_t(v)]) + 2.0f * mid / n + (n - 3.0f) * P) / n; + } + } + for (size_t f = 0; f < m.faces.size(); ++f) { + const auto& fv = m.faces[f]; + const size_t n = fv.size(); + for (size_t i = 0; i < n; ++i) + out.faces.push_back({fv[i], edgePt[key(fv[i], fv[(i + 1) % n])], facePt[f], edgePt[key(fv[(i + n - 1) % n], fv[i])]}); + } + m = std::move(out); + removeUnused(m); +} + +// ── Rings and loops ── + +namespace { +// The four-sided faces across an edge, each with the corner its way in starts at: the way in is +// corners (i, i+1), the way out (i+3, i+2), so each "first" point is on the same side. +std::vector> ringSteps(const Mesh& m, Edge e) { + const auto d = directed(m); + std::vector> steps; + std::set seen; + auto walk = [&](int a, int b, bool front) { + std::vector> got; + for (;;) { + auto it = d.find({a, b}); + if (it == d.end()) break; + const auto [f, i] = it->second; + const auto& fv = m.faces[size_t(f)]; + if (fv.size() != 4 || seen.count(f)) break; + seen.insert(f); + got.push_back({f, i}); + a = fv[(size_t(i) + 3) % 4], b = fv[(size_t(i) + 2) % 4]; + } + if (front) steps = got; + else steps.insert(steps.begin(), got.rbegin(), got.rend()); + }; + walk(e.first, e.second, true); + walk(e.second, e.first, false); + return steps; +} +} // namespace + +std::vector faceRing(const Mesh& m, std::pair edge) { + std::vector out; + for (auto [f, i] : ringSteps(m, edge)) out.push_back(f); + return out; +} + +std::vector> ringQuads(const Mesh& m, std::pair edge) { + std::vector> out; + for (auto [f, i] : ringSteps(m, edge)) { + const auto& fv = m.faces[size_t(f)]; + out.push_back({fv[size_t(i)], fv[(size_t(i) + 1) % 4], fv[(size_t(i) + 2) % 4], fv[(size_t(i) + 3) % 4]}); + } + return out; +} + +std::vector loopCut(Mesh& m, std::pair edge, int cuts) { + cuts = std::clamp(cuts, 1, 64); + const auto steps = ringSteps(m, edge); + if (steps.empty()) return {}; + std::map> pts; // cut edge (lower first) → its new points, lower end first + std::vector made; + auto along = [&](int p, int q) { + const Edge k = key(p, q); + auto it = pts.find(k); + if (it == pts.end()) { + std::vector v; + for (int c = 1; c <= cuts; ++c) { + v.push_back(int(m.verts.size())); + made.push_back(v.back()); + m.verts.push_back(glm::mix(m.verts[size_t(k.first)], m.verts[size_t(k.second)], float(c) / float(cuts + 1))); + } + it = pts.emplace(k, v).first; + } + std::vector v = it->second; + if (p > q) std::reverse(v.begin(), v.end()); + return v; + }; + std::set ring; + std::vector> added; + for (auto [f, i] : steps) { + ring.insert(f); + const auto fv = m.faces[size_t(f)]; + const int x0 = fv[size_t(i)], x1 = fv[(size_t(i) + 1) % 4], x2 = fv[(size_t(i) + 2) % 4], x3 = fv[(size_t(i) + 3) % 4]; + std::vector l0{x0}, l1{x3}; + for (int p : along(x0, x1)) l0.push_back(p); + for (int p : along(x3, x2)) l1.push_back(p); + l0.push_back(x1), l1.push_back(x2); + for (size_t k = 0; k + 1 < l0.size(); ++k) added.push_back({l0[k], l0[k + 1], l1[k + 1], l1[k]}); + } + std::vector> faces; + for (size_t f = 0; f < m.faces.size(); ++f) { + if (ring.count(int(f))) continue; + const auto& fv = m.faces[f]; + std::vector nf; + for (size_t i = 0; i < fv.size(); ++i) { + const int p = fv[i], q = fv[(i + 1) % fv.size()]; + nf.push_back(p); + if (pts.count(key(p, q))) // (a face at the ring's end: the cut points go into its edge) + for (int x : along(p, q)) nf.push_back(x); + } + faces.push_back(std::move(nf)); + } + faces.insert(faces.end(), added.begin(), added.end()); + m.faces = std::move(faces); + return made; +} + +std::set> edgeLoop(const Mesh& m, std::pair edge) { + const auto nb = neighbours(m); + std::map> facesOf; + for (size_t f = 0; f < m.faces.size(); ++f) + for (size_t i = 0; i < m.faces[f].size(); ++i) facesOf[key(m.faces[f][i], m.faces[f][(i + 1) % m.faces[f].size()])].insert(int(f)); + std::set out{key(edge.first, edge.second)}; + for (int dir = 0; dir < 2; ++dir) { + int prev = dir ? edge.second : edge.first, cur = dir ? edge.first : edge.second; + for (int n = 0; n < 100000; ++n) { + auto it = nb.find(cur); + if (it == nb.end() || it->second.size() != 4) break; + const auto& besideFaces = facesOf[key(prev, cur)]; + int next = -1; + for (int w : it->second) { + if (w == prev) continue; + bool touches = false; + for (int f : facesOf[key(cur, w)]) touches |= besideFaces.count(f) > 0; + if (!touches) next = w; + } + if (next < 0 || !out.insert(key(cur, next)).second) break; + prev = cur, cur = next; + } + } + return out; +} + +std::set linkedVerts(const Mesh& m, const std::set& verts) { + const auto nb = neighbours(m); + std::set out = verts; + std::vector todo(verts.begin(), verts.end()); + while (!todo.empty()) { + const int v = todo.back(); + todo.pop_back(); + if (auto it = nb.find(v); it != nb.end()) + for (int w : it->second) + if (out.insert(w).second) todo.push_back(w); + } + return out; +} + +std::set linkedFaces(const Mesh& m, const std::set& faces) { + std::set verts; + for (int f : faces) + if (f < int(m.faces.size())) verts.insert(m.faces[size_t(f)].begin(), m.faces[size_t(f)].end()); + verts = linkedVerts(m, verts); + std::set out; + for (size_t f = 0; f < m.faces.size(); ++f) + if (verts.count(m.faces[f][0])) out.insert(int(f)); + return out; +} + +std::set growVerts(const Mesh& m, const std::set& verts) { + const auto nb = neighbours(m); + std::set out = verts; + for (int v : verts) + if (auto it = nb.find(v); it != nb.end()) out.insert(it->second.begin(), it->second.end()); + return out; +} + +} // namespace fry + +namespace fry { + +std::set> edgeRing(const Mesh& m, std::pair edge) { + auto key = [](int a, int b) { return std::pair{std::min(a, b), std::max(a, b)}; }; + std::set> out{key(edge.first, edge.second)}; + for (const auto& q : ringQuads(m, edge)) out.insert(key(q[0], q[1])), out.insert(key(q[3], q[2])); + return out; +} + +} // namespace fry diff --git a/src/uv.cpp b/src/uv.cpp new file mode 100644 index 0000000..722ccc1 --- /dev/null +++ b/src/uv.cpp @@ -0,0 +1,175 @@ +// Laying a shape out flat for a picture: pieces facing about the same way, packed in rows. +#include "mesh.h" +#include "scene.h" + +#include +#include +#include + +namespace fry { + +std::vector> autoUnwrap(const Mesh& m, float margin) { + const size_t F = m.faces.size(); + std::vector> uv(F); + if (!F) return uv; + std::vector normal(F); + std::vector area(F); + for (size_t f = 0; f < F; ++f) normal[f] = faceNormal(m, int(f)), area[f] = faceArea(m, int(f)); + std::map, std::vector> byEdge; + for (size_t f = 0; f < F; ++f) { + const auto& fv = m.faces[f]; + for (size_t i = 0; i < fv.size(); ++i) byEdge[{std::min(fv[i], fv[(i + 1) % fv.size()]), std::max(fv[i], fv[(i + 1) % fv.size()])}].push_back(int(f)); + } + // Pieces: from the biggest faces out, taking in neighbours that face within 60 degrees. + std::vector order(F), chart(F, -1); + for (size_t f = 0; f < F; ++f) order[f] = int(f); + std::sort(order.begin(), order.end(), [&](int a, int b) { return area[size_t(a)] > area[size_t(b)]; }); + struct Chart { + std::vector faces; + glm::vec2 lo{1e30f}, hi{-1e30f}, at{0.0f}; + }; + std::vector charts; + const float limit = std::cos(glm::radians(60.0f)); + for (int seed : order) { + if (chart[size_t(seed)] >= 0) continue; + const int id = int(charts.size()); + charts.push_back({}); + glm::vec3 sum = normal[size_t(seed)] * area[size_t(seed)]; + std::vector todo{seed}; + chart[size_t(seed)] = id; + while (!todo.empty()) { + const int f = todo.back(); + todo.pop_back(); + charts.back().faces.push_back(f); + const auto& fv = m.faces[size_t(f)]; + for (size_t i = 0; i < fv.size(); ++i) + for (int g : byEdge[{std::min(fv[i], fv[(i + 1) % fv.size()]), std::max(fv[i], fv[(i + 1) % fv.size()])}]) { + const glm::vec3 avg = glm::length(sum) > 0 ? glm::normalize(sum) : normal[size_t(seed)]; + if (chart[size_t(g)] < 0 && glm::dot(normal[size_t(g)], normal[size_t(seed)]) > limit && glm::dot(normal[size_t(g)], avg) > limit) { + chart[size_t(g)] = id, sum += normal[size_t(g)] * area[size_t(g)]; + todo.push_back(g); + } + } + } + // Flat onto the piece's average facing, lined up with its first face's longest edge. + const glm::vec3 n = glm::normalize(glm::length(sum) > 1e-12f ? sum : glm::vec3(0, 0, 1)); + const auto& sf = m.faces[size_t(seed)]; + glm::vec3 u(0.0f); + for (size_t i = 0; i < sf.size(); ++i) { + const glm::vec3 d = m.verts[size_t(sf[(i + 1) % sf.size()])] - m.verts[size_t(sf[i])]; + if (glm::length(d) > glm::length(u)) u = d; + } + u -= n * glm::dot(u, n); + if (glm::length(u) < 1e-9f) u = glm::cross(n, std::abs(n.z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0)); + u = glm::normalize(u); + const glm::vec3 v = glm::cross(n, u); + Chart& c = charts.back(); + for (int f : c.faces) + for (int p : m.faces[size_t(f)]) { + const glm::vec2 q(glm::dot(m.verts[size_t(p)], u), -glm::dot(m.verts[size_t(p)], v)); // (down the picture: v flipped, so it isn't mirrored) + uv[size_t(f)].push_back(q); + c.lo = glm::min(c.lo, q), c.hi = glm::max(c.hi, q); + } + } + // Packed in rows, tallest first, into a square. + float total = 0; + for (const Chart& c : charts) total += (c.hi.x - c.lo.x) * (c.hi.y - c.lo.y); + const float side = std::sqrt(std::max(total, 1e-12f)), pad = margin * side * 1.2f; + float rowWidth = side * 1.2f; + for (const Chart& c : charts) rowWidth = std::max(rowWidth, c.hi.x - c.lo.x + 2 * pad); + std::vector byHeight(charts.size()); + for (size_t i = 0; i < charts.size(); ++i) byHeight[i] = int(i); + std::sort(byHeight.begin(), byHeight.end(), [&](int a, int b) { return charts[size_t(a)].hi.y - charts[size_t(a)].lo.y > charts[size_t(b)].hi.y - charts[size_t(b)].lo.y; }); + float x = 0, y = 0, rowH = 0, usedW = 0; + for (int i : byHeight) { + Chart& c = charts[size_t(i)]; + const float w = c.hi.x - c.lo.x + 2 * pad, h = c.hi.y - c.lo.y + 2 * pad; + if (x > 0 && x + w > rowWidth) y += rowH, x = 0, rowH = 0; + c.at = glm::vec2(x + pad, y + pad) - c.lo; + x += w, rowH = std::max(rowH, h), usedW = std::max(usedW, x); + } + const float scale = 1.0f / std::max(usedW, y + rowH); + for (const Chart& c : charts) + for (int f : c.faces) + for (glm::vec2& q : uv[size_t(f)]) q = (q + c.at) * scale; + return uv; +} + +} // namespace fry + +namespace fry { + +std::vector> uvIslands(const Mesh& m, const std::vector>& uvs) { + std::vector> out; + if (uvs.size() != m.faces.size()) return out; + // (an edge's two ends where they sit on the picture, for each face that has it) + std::map, std::vector>>> byEdge; + for (size_t f = 0; f < m.faces.size(); ++f) { + const auto& fv = m.faces[f]; + if (uvs[f].size() != fv.size()) return {}; + for (size_t i = 0; i < fv.size(); ++i) { + const size_t j = (i + 1) % fv.size(); + const bool up = fv[i] < fv[j]; + byEdge[{std::min(fv[i], fv[j]), std::max(fv[i], fv[j])}].push_back({int(f), up ? std::pair{uvs[f][i], uvs[f][j]} : std::pair{uvs[f][j], uvs[f][i]}}); + } + } + std::vector island(m.faces.size(), -1); + for (size_t start = 0; start < m.faces.size(); ++start) { + if (island[start] >= 0) continue; + out.push_back({}); + std::vector todo{int(start)}; + island[start] = int(out.size()) - 1; + while (!todo.empty()) { + const int f = todo.back(); + todo.pop_back(); + out.back().push_back(f); + const auto& fv = m.faces[size_t(f)]; + for (size_t i = 0; i < fv.size(); ++i) { + const auto& around = byEdge[{std::min(fv[i], fv[(i + 1) % fv.size()]), std::max(fv[i], fv[(i + 1) % fv.size()])}]; + const auto mine = std::find_if(around.begin(), around.end(), [&](const auto& e) { return e.first == f; })->second; + for (const auto& [g, ends] : around) + if (island[size_t(g)] < 0 && glm::distance(ends.first, mine.first) < 1e-5f && glm::distance(ends.second, mine.second) < 1e-5f) + island[size_t(g)] = island[start], todo.push_back(g); + } + } + } + return out; +} + +void remapPicture(Image& img, const Mesh& m, const std::vector>& before, const std::vector>& after, + const std::vector& faces) { + const Image src = img; + const glm::vec2 size(float(img.w), float(img.h)); + for (int f : faces) { + if (f < 0 || f >= int(m.faces.size()) || before[size_t(f)].size() != m.faces[size_t(f)].size() || after[size_t(f)].size() != m.faces[size_t(f)].size()) continue; + const auto& fv = m.faces[size_t(f)]; + for (const auto& t : triangulate(m, f)) { + glm::vec2 a[3], b[3]; + for (int k = 0; k < 3; ++k) { + const size_t c = size_t(std::find(fv.begin(), fv.end(), t[size_t(k)]) - fv.begin()); + a[k] = after[size_t(f)][c] * size, b[k] = before[size_t(f)][c] * size; + } + const float twice = (a[1].x - a[0].x) * (a[2].y - a[0].y) - (a[2].x - a[0].x) * (a[1].y - a[0].y); + if (std::abs(twice) < 1e-9f) continue; + const glm::vec2 lo = glm::max(glm::min(a[0], glm::min(a[1], a[2])) - 2.0f, glm::vec2(0.0f)); + const glm::vec2 hi = glm::min(glm::max(a[0], glm::max(a[1], a[2])) + 2.0f, size - 1.0f); + for (int y = int(lo.y); y <= int(hi.y); ++y) + for (int x = int(lo.x); x <= int(hi.x); ++x) { + const glm::vec2 q(float(x) + 0.5f, float(y) + 0.5f); + float w[3]; + bool in = true; + for (int k = 0; k < 3; ++k) { + const glm::vec2 p = a[(k + 1) % 3], e = a[(k + 2) % 3]; + w[k] = ((e.x - p.x) * (q.y - p.y) - (q.x - p.x) * (e.y - p.y)) / twice; + in &= w[k] * std::abs(twice) / std::max(glm::length(e - p), 1e-6f) >= -1.5f; // (and a little past: no seams) + } + if (!in) continue; + const glm::vec4 c = sample(src, b[0] * w[0] + b[1] * w[1] + b[2] * w[2]); + uint8_t* p = img.at(x, y); + for (int k = 0; k < 4; ++k) p[k] = uint8_t(std::lround(std::clamp(c[k], 0.0f, 1.0f) * 255.0f)); + } + } + } +} + +} // namespace fry diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..d1d190f --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,3 @@ +add_executable(mesh_test mesh_test.cpp) +target_link_libraries(mesh_test PRIVATE fry_core) +add_test(NAME mesh_test COMMAND mesh_test WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) diff --git a/tests/mesh_test.cpp b/tests/mesh_test.cpp new file mode 100644 index 0000000..5209aa7 --- /dev/null +++ b/tests/mesh_test.cpp @@ -0,0 +1,430 @@ +// The modelling core without a window: shapes are closed and face out, faces with dents +// triangulate right, extrude / inset / merge keep the mesh whole, and files round-trip. +#include "mesh.h" +#include "image.h" +#include "scene.h" +#include "shapes.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace fry; + +static int g_failures = 0; +#define CHECK(cond, ...) \ + do { \ + if (!(cond)) ++g_failures, std::printf("FAIL: " __VA_ARGS__); \ + else std::printf("ok: " __VA_ARGS__); \ + std::printf("\n"); \ + } while (0) + +// Closed: every edge has exactly two faces, running opposite ways along it. +static bool closed(const Mesh& m) { + std::map, int> e; + for (const auto& f : m.faces) + for (size_t i = 0; i < f.size(); ++i) ++e[{f[i], f[(i + 1) % f.size()]}]; + for (auto [k, n] : e) + if (n != 1 || e[{k.second, k.first}] != 1) return false; + return true; +} +// Faces out: the volume (divergence theorem) is positive. +static float volume(const Mesh& m) { + float v = 0; + for (size_t f = 0; f < m.faces.size(); ++f) + for (const auto& t : triangulate(m, int(f))) v += glm::dot(m.verts[size_t(t[0])], glm::cross(m.verts[size_t(t[1])], m.verts[size_t(t[2])])) / 6.0f; + return v; +} + +int main() { + const Mesh cube = makeCube(2.0f); + CHECK(cube.verts.size() == 8 && cube.faces.size() == 6 && closed(cube), "a cube: 8 points, 6 faces, closed"); + CHECK(std::abs(volume(cube) - 8.0f) < 1e-4f, "its faces face out (volume %.3f)", volume(cube)); + for (const auto& [name, m] : {std::pair{"cylinder", makeCylinder()}, {"cone", makeCone()}, {"sphere", makeSphere()}, {"torus", makeTorus()}}) + CHECK(closed(m) && volume(m) > 0.0f, "a %s is closed and faces out (volume %.3f)", name, volume(m)); + + // An L: six corners, one dent. Ear clipping gives 4 triangles covering its 3 m². + const Mesh l{{{0, 0, 0}, {2, 0, 0}, {2, 1, 0}, {1, 1, 0}, {1, 2, 0}, {0, 2, 0}}, {{0, 1, 2, 3, 4, 5}}}; + const auto tris = triangulate(l, 0); + float area = 0; + for (const auto& t : tris) area += glm::length(glm::cross(l.verts[size_t(t[1])] - l.verts[size_t(t[0])], l.verts[size_t(t[2])] - l.verts[size_t(t[0])])) / 2; + CHECK(tris.size() == 4 && std::abs(area - 3.0f) < 1e-4f, "an L shape triangulates (%zu triangles, %.3f m2)", tris.size(), area); + + // A drawn outline made solid: closed, the right thickness. + const Mesh shape = extrudeOutline({{0, 0, 0}, {0, 2, 0}, {0, 2, 1}, {0, 1, 1}, {0, 1, 2}, {0, 0, 2}}, glm::vec3(1, 0, 0), 0.05f); + CHECK(closed(shape) && std::abs(volume(shape) - 3.0f * 0.05f) < 1e-4f, "an outline made solid (volume %.4f)", volume(shape)); + + // Extrude the cube's top up 1 m: 12 points, 10 faces, still closed, 4 m³ more. + Mesh m = cube; + extrudeFaces(m, {0}, 1.0f); + CHECK(m.verts.size() == 12 && m.faces.size() == 10 && closed(m) && std::abs(volume(m) - 12.0f) < 1e-4f, "extrude pulls a face out (volume %.2f)", volume(m)); + insetFaces(m, {0}, 0.5f); + CHECK(m.faces.size() == 14 && closed(m) && std::abs(volume(m) - 12.0f) < 1e-4f, "inset keeps it closed"); + Mesh d = m; + mergeByDistance(d, 0.01f); + CHECK(d.verts.size() == m.verts.size(), "merge by distance leaves separate points alone"); + Mesh two = cube; + two.verts.push_back(two.verts[0] + glm::vec3(0.0001f)); + two.faces[1][0] = 8; + CHECK(mergeByDistance(two, 0.001f) == 1 && closed(two), "and joins points on top of each other"); + deleteFaces(m, {0}); + CHECK(m.faces.size() == 13 && !closed(m), "deleting a face opens it"); + + // Rounding: every edge of a cube, one edge, a rounded box; all stay closed, a bit smaller. + for (int segs : {1, 3, 4}) { + Mesh b = makeCube(1.0f); + bevelEdges(b, sharpEdges(b, 30.0f), 0.1f, segs); + CHECK(closed(b) && volume(b) < 1.0f && volume(b) > 0.93f, "rounding all a cube's edges, %d steps: closed (volume %.4f)", segs, volume(b)); + } + { + Mesh b = makeCube(1.0f); + bevelEdges(b, {{4, 5}}, 0.2f, 1); + CHECK(closed(b) && std::abs(volume(b) - (1.0f - 0.02f)) < 1e-3f, "cutting off one edge: closed, 0.02 m3 gone (volume %.4f)", volume(b)); + Mesh c = makeCube(1.0f); + bevelEdges(c, {{4, 5}, {5, 7}}, 0.1f, 3); + CHECK(closed(c), "rounding two edges that meet: closed"); + } + const Mesh rb = makeRoundedBox({1, 1, 1}, 0.25f, 4); + CHECK(closed(rb) && std::abs(volume(rb) - 0.86f) < 0.02f, "a rounded box is closed (volume %.4f)", volume(rb)); + Mesh sub = makeCube(1.0f); + subdivide(sub, true); + CHECK(sub.faces.size() == 24 && closed(sub) && volume(sub) < 1.0f, "smoothing a cube: 24 faces, closed, rounder (volume %.3f)", volume(sub)); + Mesh sharp = makeCube(1.0f); + subdivide(sharp, true, 30.0f); + CHECK(std::abs(volume(sharp) - 1.0f) < 1e-4f, "smoothing that keeps sharp edges keeps a cube a cube"); + Mesh cut = makeCube(1.0f); + const auto made = loopCut(cut, {4, 5}, 2); + CHECK(made.size() == 8 && cut.faces.size() == 14 && closed(cut) && std::abs(volume(cut) - 1.0f) < 1e-4f, "two loop cuts round a cube (%zu points, %zu faces)", + made.size(), cut.faces.size()); + Mesh grid = makeCube(1.0f); + subdivide(grid, false); + std::pair mid{-1, -1}; // an edge between a side's middle and an edge's middle: its loop goes all round + for (auto e : edges(grid)) + if (e.first >= 8 && e.second >= 8) mid = e; + CHECK(edgeLoop(grid, mid).size() == 8, "an edge loop goes all round (%zu edges)", edgeLoop(grid, mid).size()); + for (const ShapeRecipe& r : shapeRecipes()) { + const Mesh m = makeFromRecipe(r, defaultSettings(r)); + const bool flat = std::string(r.id) == "plane" || std::string(r.id) == "circle"; + CHECK(!m.faces.empty() && std::abs(boundsMin(m).z) < 1e-4f && (flat || (closed(m) && volume(m) > 0)), "the %s stands on the ground%s", r.name, + flat ? "" : ", closed, facing out"); + } + Object fin; + fin.mesh = makeCube(1.0f), fin.mirror = 1, fin.round = 0.05f; + fin.mesh.verts[0].x = fin.mesh.verts[2].x = fin.mesh.verts[4].x = fin.mesh.verts[6].x = 0.0f; // (a half cube on the mirror line) + const Mesh fm = finishedMesh(fin); + CHECK(closed(fm) && std::abs(volume(fm) - 1.0f) < 0.05f, "a half mirrored, then rounded: one closed piece (volume %.3f)", volume(fm)); + + // The knife: a line seen from the front (+X). Only what's seen: the front face is cut in two. + { + Mesh k = makeCube(1.0f); + const glm::vec3 eye(5, 0, 0); + const auto cutEdges = knifeCut(k, eye, {{0, 0.1f, 2}, {0, 0.1f, -2}}, false); + CHECK(cutEdges.size() == 1 && k.faces.size() == 7 && closed(k), "the knife cuts the face it's drawn over (%zu edges, %zu faces)", cutEdges.size(), k.faces.size()); + Mesh t = makeCube(1.0f); + const auto all = knifeCut(t, eye, {{0, 0.1f, 2}, {0, 0.1f, -2}}, true); + CHECK(all.size() == 4 && closed(t), "through: all the way round (%zu edges)", all.size()); + const auto pieces = splitAlong(t, all, true); + float v = 0; + for (const auto& p : pieces) v += volumeOf(t, p); + CHECK(pieces.size() == 2 && closed(t) && std::abs(v - 1.0f) < 1e-3f && std::abs(volume(t) - 1.0f) < 1e-3f, "pulled apart and filled: 2 closed pieces (%zu, volume %.4f)", + pieces.size(), v); + Mesh o = makeCube(1.0f); + const auto e2 = knifeCut(o, eye, {{0, 0.1f, 2}, {0, 0.1f, -2}}, true); + splitAlong(o, e2, false); + CHECK(!closed(o) && o.faces.size() == 10, "pulled apart, left open: hollow (%zu faces)", o.faces.size()); + Mesh l = makeCube(1.0f); // a bent line: an L across the front + const auto le = knifeCut(l, eye, {{0, 0.6f, 0.2f}, {0, -0.2f, 0.2f}, {0, -0.2f, -0.6f}}, false); + CHECK(le.size() >= 2 && closed(l), "a bent line cuts along both its steps (%zu edges)", le.size()); + } + // Holes: round, through a cube and as a pocket. + { + auto frontFace = [](const Mesh& m) { + for (size_t f = 0; f < m.faces.size(); ++f) + if (faceNormal(m, int(f)).x > 0.99f) return int(f); + return -1; + }; + Mesh h = makeCube(1.0f); + int f = frontFace(h); + const auto ring = holeOutline(h, f, faceCenter(h, f), 0.2f, 16); + const float hole = 8 * 0.04f * std::sin(2 * 3.14159265f / 16); + CHECK(cutHole(h, f, ring, 0) && closed(h) && std::abs(volume(h) - (1 - hole)) < 1e-3f, "a round hole all the way through (volume %.4f)", volume(h)); + Mesh p = makeCube(1.0f); + f = frontFace(p); + CHECK(cutHole(p, f, holeOutline(p, f, faceCenter(p, f) + glm::vec3(0, 0.1f, 0), 0.15f, 4), 0.3f) && closed(p) && + std::abs(volume(p) - (1 - 0.09f * 0.3f)) < 1e-3f, + "a square pocket 0.3 deep (volume %.4f)", volume(p)); + Mesh big = makeCube(1.0f); + f = frontFace(big); + CHECK(!cutHole(big, f, holeOutline(big, f, faceCenter(big, f), 0.7f, 16), 0) && big.faces.size() == 6, "a hole bigger than the face: refused, nothing changed"); + } + // Laid out flat for a picture: everything inside 0..1, nothing on top of anything else. + { + Mesh u = makeCylinder(16, 0.5f, 1.0f); + const auto uv = autoUnwrap(u); + bool inside = uv.size() == u.faces.size(); + float area = 0; + for (size_t f = 0; f < uv.size(); ++f) { + inside &= uv[f].size() == u.faces[f].size(); + for (size_t i = 0; i < uv[f].size(); ++i) { + inside &= uv[f][i].x >= 0 && uv[f][i].x <= 1 && uv[f][i].y >= 0 && uv[f][i].y <= 1; + const glm::vec2 a = uv[f][i], b = uv[f][(i + 1) % uv[f].size()]; + area += (a.x * b.y - b.x * a.y) * 0.5f; + } + } + CHECK(inside && area < -0.2f && area >= -1.0f, "laid flat inside the picture, not mirrored (area %.3f: y runs down)", area); + } + + // Pictures: a stamped square bent into a corner pin lands where its corners say. + { + Image dst = makeImage(64, 64, {0, 0, 0, 1}), src = makeImage(8, 8, {1, 0, 0, 1}); + stampQuad(dst, src, {glm::vec2(10, 10), glm::vec2(50, 14), glm::vec2(40, 50), glm::vec2(12, 40)}); + const glm::vec2 mid = mapSquare(squareToQuad({glm::vec2(10, 10), glm::vec2(50, 14), glm::vec2(40, 50), glm::vec2(12, 40)}), {0.5f, 0.5f}); + CHECK(pixel(dst, int(mid.x), int(mid.y)).r > 0.9f && pixel(dst, 2, 2).r < 0.1f && pixel(dst, 60, 60).r < 0.1f, "a picture pinned by its corners"); + Image f = makeImage(16, 16, {1, 1, 1, 1}); + for (int y = 0; y < 16; ++y) f.at(8, y)[0] = 0; // a line down the middle + floodFill(f, {2, 2}, {0, 0, 1, 1}, 0.1f); + CHECK(pixel(f, 0, 15).b > 0.9f && pixel(f, 0, 15).r < 0.1f && pixel(f, 12, 3).r > 0.9f, "fill stops at a line"); + Stroke st; + Image s2 = makeImage(16, 16, {1, 1, 1, 1}); + st.begin(s2, {0, 0, 0, 0.5f}); + st.dab(s2, {8, 8}, 4, 1), st.dab(s2, {8, 8}, 4, 1); + CHECK(std::abs(pixel(s2, 8, 8).r - 0.5f) < 0.01f, "a stroke over itself doesn't build up"); + } + + // A sticker hanging over the top edge of a cube's front folds onto the top, and no further. + { + Object o; + o.mesh = makeCube(1.0f), o.uvs = autoUnwrap(o.mesh), o.picture = std::make_shared(makeImage(256, 256, {1, 1, 1, 1})); + int front = 0; + for (size_t f = 0; f < o.mesh.faces.size(); ++f) + if (faceNormal(o.mesh, int(f)).x > 0.99f) front = int(f); + PictureLayer l; + l.image = std::make_shared(makeImage(8, 8, {1, 0, 0, 1})); + l.face = front, l.centre = {0.5f, 0, 0.4f}, l.up = {0, 0, 1}, l.width = l.height = 0.4f; + o.layers.push_back(l); + std::vector owner; + composePicture(o, &owner); + auto covered = [&](int f) { + glm::vec2 lo(1), hi(0); + for (glm::vec2 q : o.uvs[size_t(f)]) lo = glm::min(lo, q), hi = glm::max(hi, q); + int n = 0; + for (int y = int(lo.y * 256) + 2; y < int(hi.y * 256) - 2; ++y) + for (int x = int(lo.x * 256) + 2; x < int(hi.x * 256) - 2; ++x) n += owner[size_t(y) * 256 + size_t(x)] == 1; + return n; + }; + int top = 0, bottom = 0, back = 0; + for (size_t f = 0; f < o.mesh.faces.size(); ++f) { + const glm::vec3 n = faceNormal(o.mesh, int(f)); + if (n.z > 0.99f) top = covered(int(f)); + if (n.z < -0.99f) bottom = covered(int(f)); + if (n.x < -0.99f) back = covered(int(f)); + } + CHECK(covered(front) > 200 && top > 50 && bottom == 0 && back == 0, "a sticker over an edge folds onto the next face (front %d, top %d, bottom %d)", covered(front), top, bottom); + } + + // At a corner each face gets the part of the sticker that reaches it straight: a sticker of four + // colours (red top-left, green top-right, blue bottom-left, white bottom-right) at the front's + // top-right corner puts red and green on the top, green and white on the right, no blue there. + { + Object o; + o.mesh = makeCube(1.0f), o.uvs = autoUnwrap(o.mesh), o.picture = std::make_shared(makeImage(256, 256, {0, 0, 0, 1})); + Image four = makeImage(64, 64, {1, 1, 1, 1}); + for (int y = 0; y < 64; ++y) + for (int x = 0; x < 64; ++x) { + uint8_t* p = four.at(x, y); + if (y < 32) p[0] = x < 32 ? 255 : 0, p[1] = x < 32 ? 0 : 255, p[2] = 0; + else if (x < 32) p[0] = 0, p[1] = 0, p[2] = 255; + } + int front = 0; + for (size_t f = 0; f < o.mesh.faces.size(); ++f) + if (faceNormal(o.mesh, int(f)).x > 0.99f) front = int(f); + PictureLayer l; + l.image = std::make_shared(four); + l.face = front, l.centre = {0.5f, 0.42f, 0.42f}, l.up = {0, 0, 1}, l.width = l.height = 0.4f; + o.layers.push_back(l); + const Image shown = composePicture(o); + auto count = [&](glm::vec3 n, int r, int g, int b) { + int c = 0; + for (size_t f = 0; f < o.mesh.faces.size(); ++f) { + if (glm::dot(faceNormal(o.mesh, int(f)), n) < 0.99f) continue; + glm::vec2 lo(1), hi(0); + for (glm::vec2 q : o.uvs[f]) lo = glm::min(lo, q), hi = glm::max(hi, q); + for (int y = int(lo.y * 256) + 2; y < int(hi.y * 256) - 2; ++y) + for (int x = int(lo.x * 256) + 2; x < int(hi.x * 256) - 2; ++x) { + const uint8_t* p = shown.px.data() + (size_t(y) * 256 + size_t(x)) * 4; + c += std::abs(p[0] - r) < 60 && std::abs(p[1] - g) < 60 && std::abs(p[2] - b) < 60; + } + } + return c; + }; + const glm::vec3 top(0, 0, 1), right(0, 1, 0); + // Moved so its middle is past the top edge: it goes onto the top, bent down over the front. + PictureLayer moved = l; + moved.centre = {0.5f, 0.0f, 0.6f}; + settleSticker(o.mesh, moved); + CHECK(faceNormal(o.mesh, moved.face).z > 0.99f && glm::distance(moved.centre, glm::vec3(0.4f, 0.0f, 0.5f)) < 1e-3f && glm::dot(moved.up, glm::vec3(-1, 0, 0)) > 0.99f, + "a sticker's middle past an edge: it moves onto that face (%g %g %g)", moved.centre.x, moved.centre.y, moved.centre.z); + CHECK(count(top, 255, 0, 0) > 20 && count(top, 0, 255, 0) > 20 && count(top, 0, 0, 255) == 0 && count(right, 0, 255, 0) > 20 && + count(right, 255, 255, 255) > 20 && count(right, 255, 0, 0) == 0 && count(right, 0, 0, 255) == 0, + "a sticker at a corner: each face gets its own part (top red %d green %d blue %d; right green %d white %d red %d blue %d)", count(top, 255, 0, 0), + count(top, 0, 255, 0), count(top, 0, 0, 255), count(right, 0, 255, 0), count(right, 255, 255, 255), count(right, 255, 0, 0), count(right, 0, 0, 255)); + } + + // For printing: a cube is closed and right way out; with a face gone it has a hole, closed + // again by Close holes; turned inside out, Face outward turns it back. + { + Mesh m = makeCube(1.0f); + PrintCheck c = checkForPrint(m, 45, -0.5f); + CHECK(c.closed() && !c.insideOut && std::abs(c.volume - 1) < 1e-4f && c.pieces == 1 && c.overhangArea == 0, "a cube is ready to print"); + m.faces.pop_back(); + c = checkForPrint(m, 45, -0.5f); + CHECK(c.openEdges == 4 && closeHoles(m) == 1 && checkForPrint(m).closed() && std::abs(checkForPrint(m).volume - 1) < 1e-4f, "a hole found and closed"); + std::reverse(m.faces[2].begin(), m.faces[2].end()); + CHECK(checkForPrint(m).flippedEdges == 4 && faceOutward(m) == 1 && checkForPrint(m).closed() && checkForPrint(m).volume > 0, "a face turned the wrong way, turned back"); + for (auto& f : m.faces) std::reverse(f.begin(), f.end()); + CHECK(checkForPrint(m).insideOut && faceOutward(m) == 6 && !checkForPrint(m).insideOut, "inside out, turned right way out"); + Mesh t = makeCube(1.0f); + transform(t, glm::translate(glm::mat4(1.0f), glm::vec3(0, 0, 2))); + CHECK(std::abs(checkForPrint(t, 45, -0.5f).overhangArea - 1) < 1e-4f, "a cube held up in the air overhangs by its bottom"); + } + // The layout's frames: a cube laid out in six; one moved with what's painted on it. + { + Mesh m = makeCube(1.0f); + auto uv = autoUnwrap(m); + const auto islands = uvIslands(m, uv); + CHECK(!islands.empty() && islands.size() <= 6, "a cube's layout has frames (%zu)", islands.size()); + Image im = makeImage(64, 64, {0, 0, 0, 1}); + int f = islands[0][0]; + glm::vec2 c(0); + for (glm::vec2 q : uv[size_t(f)]) c += q / float(uv[size_t(f)].size()); + im.at(int(c.x * 64), int(c.y * 64))[0] = 255; + im.at(int(c.x * 64) + 1, int(c.y * 64))[0] = 255, im.at(int(c.x * 64), int(c.y * 64) + 1)[0] = 255, im.at(int(c.x * 64) + 1, int(c.y * 64) + 1)[0] = 255; + auto moved = uv; + for (glm::vec2& q : moved[size_t(f)]) q += glm::vec2(0.0f, 0.0f); + remapPicture(im, m, uv, moved, {f}); + CHECK(im.at(int(c.x * 64), int(c.y * 64))[0] > 100, "moving a frame nowhere keeps what's painted"); + } + std::filesystem::create_directories(std::filesystem::temp_directory_path() / "fry3dx_test"); + CHECK(exportModel((std::filesystem::temp_directory_path() / "fry3dx_test" / "c.3mf").string(), [] { Scene s; Object o; o.mesh = makeCube(0.02f); s.objects.push_back(o); return s; }(), {}), + "exports 3MF"); + + // Hole shapes: a hexagon for an M3 nut is 5.5 across its flats; a 20 x 10 slot; a triangle. + // Outline only: the face gets a new face inside, nothing taken away (the volume stays). + { + Mesh m = makeCube(0.04f); + int top = 0; + for (size_t f = 0; f < m.faces.size(); ++f) + if (faceNormal(m, int(f)).z > 0.99f) top = int(f); + const glm::vec3 c = faceCenter(m, top); + const auto hex = holeOutline(m, top, c, HoleShape::Hexagon, 0.0055f); + float flats = 1e9f; + for (size_t i = 0; i < 6; ++i) flats = std::min(flats, glm::distance((hex[i] + hex[(i + 1) % 6]) * 0.5f, c) * 2.0f); + CHECK(hex.size() == 6 && std::abs(flats - 0.0055f) < 1e-6f, "a hexagon 5.5 mm across its flats (%g)", flats * 1000); + const auto slot = holeOutline(m, top, c, HoleShape::Rectangle, 0.02f, 0.01f); + CHECK(slot.size() == 4 && std::abs(glm::distance(slot[0], slot[1]) - 0.02f) < 1e-6f && std::abs(glm::distance(slot[1], slot[2]) - 0.01f) < 1e-6f, "a 20 x 10 mm slot"); + const auto tri = holeOutline(m, top, c, HoleShape::Triangle, 0.012f); + CHECK(tri.size() == 3 && std::abs(glm::distance(tri[0], tri[1]) - 0.012f) < 1e-6f, "a triangle with 12 mm sides"); + Mesh p = m; + CHECK(cutHole(p, top, hex, 0.0024f) && std::abs(checkForPrint(p).volume - (0.04f * 0.04f * 0.04f - 0.0024f * 0.0055f * 0.0055f * std::sqrt(3.0f) / 2.0f)) < 1e-10f && checkForPrint(p).closed(), + "a pocket for an M3 nut, 2.4 mm deep, closed"); + const size_t before = m.faces.size(); + const int f = imprintOutline(m, top, slot); + CHECK(f >= 0 && m.faces.size() == before + 4 && std::abs(faceArea(m, f) - 0.0002f) < 1e-9f && checkForPrint(m).closed() && std::abs(checkForPrint(m).volume - 0.000064f) < 1e-10f, + "outline only: a new 20 x 10 mm face, nothing taken away"); + CHECK(imprintOutline(m, top, holeOutline(m, top, c, HoleShape::Round, 0.08f)) < 0, "an outline bigger than the face isn't cut"); + } + + // Combining: two 1 m cubes half overlapping make 1.5 m3 added, 0.5 taken away, 0.5 overlapping; + // each closed all round (ready to print), its flat sides joined back into whole faces. + { + Mesh b = makeCube(1.0f); + transform(b, glm::translate(glm::mat4(1.0f), glm::vec3(0.5f, 0, 0))); + const char* names[] = {"added", "taken away", "overlapping"}; + const float want[] = {1.5f, 0.5f, 0.5f}; + for (int op = 0; op < 3; ++op) { + Mesh a = makeCube(1.0f); + const bool ok = combine(a, b, Combine(op)); + const PrintCheck c = checkForPrint(a); + CHECK(ok && std::abs(c.volume - want[op]) < 1e-4f && c.closed() && a.faces.size() <= 14, "cubes %s: %g m3, %d open edges, %d flipped, %zu faces", names[op], c.volume, + c.openEdges, c.flippedEdges, a.faces.size()); + } + Mesh a = makeCube(1.0f); + const Mesh cyl = makeCylinder(24, 0.2f, 3.0f); + Mesh drill = cyl; + transform(drill, glm::translate(glm::mat4(1.0f), glm::vec3(0, 0, -1.5f))); + CHECK(combine(a, drill, Combine::Subtract) && checkForPrint(a).closed() && checkForPrint(a).volume < 1.0f && checkForPrint(a).volume > 0.85f, + "a cylinder drilled through a cube: closed, %g m3", checkForPrint(a).volume); + } + // An edge's ring: across a cube's side, the four edges going round it. + { + const Mesh c = makeCube(1.0f); + const auto& f = c.faces[0]; + CHECK(edgeRing(c, {std::min(f[0], f[1]), std::max(f[0], f[1])}).size() == 4, "a cube edge's ring is 4 edges (%zu)", edgeRing(c, {std::min(f[0], f[1]), std::max(f[0], f[1])}).size()); + } + // Thicken: a plane 1 m square made 0.1 thick is a closed slab of 0.1 m3; a cube made hollow. + { + Mesh p = makePlane(1.0f); + thicken(p, 0.1f); + CHECK(checkForPrint(p).closed() && std::abs(checkForPrint(p).volume - 0.1f) < 1e-4f, "a plane thickened into a slab (%g m3)", checkForPrint(p).volume); + Mesh c = makeCube(1.0f); + thicken(c, 0.1f); + CHECK(checkForPrint(c).closed() && std::abs(checkForPrint(c).volume - (1.0f - 0.8f * 0.8f * 0.8f)) < 1e-3f, "a cube hollowed, walls 0.1 thick (%g m3)", checkForPrint(c).volume); + } + + // Save, load: the same; export writes a glTF and its .bin. + Scene s; + Object o; + o.id = 1, o.name = "Box \"one\"", o.mesh = cube, o.position = {1, 2, 3}, o.rotation = fromEuler({10, 20, 30}), o.color = {0.5f, 0.25f, 1}; + s.objects.push_back(o); + const auto dir = std::filesystem::temp_directory_path() / "fry3dx_test"; + std::filesystem::create_directories(dir); + CHECK(saveScene((dir / "a.fry").string(), s), "saves"); + auto back = loadScene((dir / "a.fry").string()); + CHECK(back && back->objects.size() == 1 && back->objects[0].name == o.name && back->objects[0].mesh.faces == cube.faces && + glm::length(toEuler(back->objects[0].rotation) - glm::vec3(10, 20, 30)) < 1e-3f, + "loads back the same"); + CHECK(exportModel((dir / "a.gltf").string(), s, {}) && std::filesystem::file_size(dir / "a.bin") > 0, "exports glTF"); + for (const auto& [ext, what] : exportFormats()) + CHECK(exportModel((dir / ("b" + ext)).string(), s, {}) && std::filesystem::file_size(dir / ("b" + ext)) > 100, "exports %s", ext.c_str()); + + // Its own file: one packed file with its pictures and layers, back the same; old text ones open too. + { + Scene p = s; + Image im = makeImage(256, 256, {1, 1, 1, 1}); + for (int y = 0; y < 256; ++y) + for (int x = 0; x < 256; ++x) im.at(x, y)[0] = uint8_t(x ^ y), im.at(x, y)[1] = uint8_t(y); + p.objects[0].picture = std::make_shared(im); + p.objects[0].uvs = autoUnwrap(p.objects[0].mesh); + PictureLayer l; + l.name = "Logo", l.image = std::make_shared(makeImage(3, 5, {1, 0, 0, 0.5f})), l.width = 0.25f; + p.objects[0].layers.push_back(l); + CHECK(savePackage((dir / "p.fry").string(), p) && isPackage((dir / "p.fry").string()), "saves its own file"); + auto q = loadPackage((dir / "p.fry").string()); + CHECK(q && q->objects.size() == 1 && q->objects[0].picture && q->objects[0].picture->px == im.px && q->objects[0].layers.size() == 1 && + q->objects[0].layers[0].image->px == l.image->px && q->objects[0].layers[0].name == "Logo" && q->objects[0].layers[0].width == 0.25f && + q->objects[0].uvs == p.objects[0].uvs, + "loads its own file back the same, pictures exactly"); + CHECK(std::filesystem::file_size(dir / "p.fry") < 256 * 256 * 4 / 8, "packed small (%zu bytes)", size_t(std::filesystem::file_size(dir / "p.fry"))); + CHECK(loadPackage((dir / "a.fry").string()).has_value(), "opens the old text .fry too"); + Scene g = s; + g.folders = {{50, "Parts", 0, true, true, false}, {51, "Screws", 50, false, true, true}}; + g.objects[0].folder = 51; + CHECK(savePackage((dir / "g.fry").string(), g), "saves folders"); + auto h = loadPackage((dir / "g.fry").string()); + CHECK(h && h->folders.size() == 2 && h->folders[1].name == "Screws" && h->folders[1].parent == h->folders[0].id && !h->folders[1].open && h->folders[1].locked && + h->objects[0].folder == h->folders[1].id, + "folders inside folders come back, the object in its folder"); + } + std::ifstream j(dir / "a.gltf"); + const std::string text((std::istreambuf_iterator(j)), {}); + CHECK(text.find("\"translation\":[2,3,1]") != std::string::npos, "in glTF's axes (Y up): the position is [2,3,1]"); + + std::printf("%s (%d failure%s)\n", g_failures ? "FAILED" : "passed", g_failures, g_failures == 1 ? "" : "s"); + return g_failures ? 1 : 0; +}