From 42f70242117c409b914f991724b8957663d145f5 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Tue, 15 Sep 2026 19:08:49 -0700 Subject: [PATCH] Add processed assets and improve browser rendering and runtime support Fetch meshoptimizer 1.2 through a checksum-pinned CMake dependency. Add checked mesh codecs, BC texture compression and fallback decoding, processed glTF containers, packed GPU geometry and shared texture ownership. Extend PBR rendering with local shadows, atmospheric attenuation and optional surface/material buffers. Preserve skin attributes and draw-time vertex defaults, and evaluate shadow derivatives before varying bounds checks. Fix binary-array allocation stride and release serialization buffers after normal and failed callbacks. Move threaded WebAudio mixing to a pthread-fed PCM ring. Correct browser input callback teardown and snapshot payload ownership, add registered live capture/report transport, and honor Wasm release symbol requests. Add focused regression coverage and public API documentation. --- CMakeLists.txt | 1 + daslib/ARCHITECTURE.md | 8 + daslib/daspkg.das | 4 +- daslib/fio.das | 4 +- doc/reflections/das2rst.das | 37 + ...hopt_decode_indices-0xc16e60c000f53bf4.rst | 1 + ...opt_decode_vertices-0x25019ef686d88461.rst | 1 + ...hopt_encode_indices-0xcebc111796f37b7c.rst | 1 + ...opt_encode_vertices-0xe1ad9380a82a899d.rst | 1 + ...hopt_generate_remap-0xdbd2de7ee479a3c2.rst | 1 + ...hopt_optimize_cache-0x9f5637f748a14874.rst | 1 + ...hopt_optimize_fetch-0xcd80344cb34f875f.rst | 1 + ...shopt_remap_indices-0x69706c78732a40b2.rst | 1 + ...hopt_remap_vertices-0xe33b3a97b651e4e8.rst | 1 + ...stb_compress_blocks-0xcc27189d5d081eae.rst | 1 + .../stdlib/handmade/module-gltf_processed.rst | 1 + .../stdlib/handmade/module-meshoptimizer.rst | 1 + .../stdlib/handmade/module-texture_blocks.rst | 1 + doc/source/stdlib/sec_media.rst | 3 + modules/dasAudio/ARCHITECTURE.md | 19 + modules/dasAudio/CMakeLists.txt | 2 +- modules/dasAudio/src/dasAudio.cpp | 87 ++- modules/dasGLTF/.das_module | 2 +- modules/dasGLTF/ARCHITECTURE.md | 26 + modules/dasGLTF/README.md | 121 +++ modules/dasGLTF/gltf/gltf_atmosphere.das | 22 + modules/dasGLTF/gltf/gltf_gl.das | 90 ++- modules/dasGLTF/gltf/gltf_pbr.das | 314 +++++++- modules/dasGLTF/gltf/gltf_pbr_common.das | 19 + modules/dasGLTF/gltf/gltf_processed.das | 324 ++++++++ modules/dasGLTF/gltf/gltf_processed_gl.das | 145 ++++ modules/dasGLTF/gltf/gltf_scene.das | 17 +- .../tests/test_rendering_contracts.das | 26 + .../dasGLTF/tests/test_surface_controls.das | 21 + modules/dasGLTF/tools/convert.das | 71 ++ modules/dasImgui/ARCHITECTURE.md | 13 + modules/dasImgui/REVIEW.md | 2 +- modules/dasImgui/src/module_imgui_app.cpp | 54 +- .../tests/test_snapshot_payload_ownership.das | 16 + .../dasImgui/widgets/imgui_boost_runtime.das | 91 +-- .../widgets/imgui_drawlist_builtin.das | 15 + modules/dasImgui/widgets/imgui_live.das | 1 + modules/dasLiveHost/.das_module | 2 +- modules/dasLiveHost/live/README_capture.md | 29 + modules/dasLiveHost/live/live_capture.das | 131 ++++ .../tests/test_capture_transport.cjs | 29 + .../dasLiveHost/tests/test_live_capture.das | 47 ++ modules/dasLiveHost/web/README.md | 34 + modules/dasLiveHost/web/live_capture.js | 95 +++ modules/dasMeshOptimizer/.das_module | 8 + modules/dasMeshOptimizer/CMakeLists.txt | 55 ++ .../dasMeshOptimizer/MESHOPTIMIZER.LICENSE | 21 + modules/dasMeshOptimizer/README.md | 24 + .../dasMeshOptimizer/src/dasMeshOptimizer.cpp | 100 +++ .../dasMeshOptimizer/src/dasMeshOptimizer.h | 13 + .../tests/test_allocation_limits.das | 31 + .../tests/test_meshoptimizer.das | 55 ++ modules/dasStbImage/.das_module | 1 + modules/dasStbImage/CMakeLists.txt | 2 + modules/dasStbImage/README.md | 24 + modules/dasStbImage/src/dasStbImage.cpp | 36 + modules/dasStbImage/src/dasStbImage.h | 2 + modules/dasStbImage/src/dasStbImage_impl.cpp | 3 + modules/dasStbImage/src/stb_dxt.h | 719 ++++++++++++++++++ modules/dasStbImage/src/stb_dxt.version | 4 + .../dasStbImage/stbimage/texture_blocks.das | 123 +++ .../tests/test_allocation_limits.das | 22 + .../dasStbImage/tests/test_texture_blocks.das | 73 ++ skills/daslang/references/everything.md | 99 +++ src/simulate/bin_serializer.cpp | 31 +- tests/dasGLTF/test_gltf_atmosphere.das | 17 + tests/dasGLTF/test_gltf_pbr_common.das | 44 ++ tests/dasGLTF/test_gltf_processed.das | 91 +++ tests/language/bin_serializer.das | 57 ++ utils/daspkg/README.md | 7 + utils/daspkg/commands.das | 10 +- 76 files changed, 3485 insertions(+), 122 deletions(-) create mode 100644 doc/source/stdlib/handmade/function-meshoptimizer-meshopt_decode_indices-0xc16e60c000f53bf4.rst create mode 100644 doc/source/stdlib/handmade/function-meshoptimizer-meshopt_decode_vertices-0x25019ef686d88461.rst create mode 100644 doc/source/stdlib/handmade/function-meshoptimizer-meshopt_encode_indices-0xcebc111796f37b7c.rst create mode 100644 doc/source/stdlib/handmade/function-meshoptimizer-meshopt_encode_vertices-0xe1ad9380a82a899d.rst create mode 100644 doc/source/stdlib/handmade/function-meshoptimizer-meshopt_generate_remap-0xdbd2de7ee479a3c2.rst create mode 100644 doc/source/stdlib/handmade/function-meshoptimizer-meshopt_optimize_cache-0x9f5637f748a14874.rst create mode 100644 doc/source/stdlib/handmade/function-meshoptimizer-meshopt_optimize_fetch-0xcd80344cb34f875f.rst create mode 100644 doc/source/stdlib/handmade/function-meshoptimizer-meshopt_remap_indices-0x69706c78732a40b2.rst create mode 100644 doc/source/stdlib/handmade/function-meshoptimizer-meshopt_remap_vertices-0xe33b3a97b651e4e8.rst create mode 100644 doc/source/stdlib/handmade/function-stbimage-stb_compress_blocks-0xcc27189d5d081eae.rst create mode 100644 doc/source/stdlib/handmade/module-gltf_processed.rst create mode 100644 doc/source/stdlib/handmade/module-meshoptimizer.rst create mode 100644 doc/source/stdlib/handmade/module-texture_blocks.rst create mode 100644 modules/dasGLTF/ARCHITECTURE.md create mode 100644 modules/dasGLTF/gltf/gltf_atmosphere.das create mode 100644 modules/dasGLTF/gltf/gltf_processed.das create mode 100644 modules/dasGLTF/gltf/gltf_processed_gl.das create mode 100644 modules/dasGLTF/tests/test_rendering_contracts.das create mode 100644 modules/dasGLTF/tests/test_surface_controls.das create mode 100644 modules/dasGLTF/tools/convert.das create mode 100644 modules/dasImgui/ARCHITECTURE.md create mode 100644 modules/dasImgui/tests/test_snapshot_payload_ownership.das create mode 100644 modules/dasLiveHost/live/README_capture.md create mode 100644 modules/dasLiveHost/live/live_capture.das create mode 100644 modules/dasLiveHost/tests/test_capture_transport.cjs create mode 100644 modules/dasLiveHost/tests/test_live_capture.das create mode 100644 modules/dasLiveHost/web/README.md create mode 100644 modules/dasLiveHost/web/live_capture.js create mode 100644 modules/dasMeshOptimizer/.das_module create mode 100644 modules/dasMeshOptimizer/CMakeLists.txt create mode 100644 modules/dasMeshOptimizer/MESHOPTIMIZER.LICENSE create mode 100644 modules/dasMeshOptimizer/README.md create mode 100644 modules/dasMeshOptimizer/src/dasMeshOptimizer.cpp create mode 100644 modules/dasMeshOptimizer/src/dasMeshOptimizer.h create mode 100644 modules/dasMeshOptimizer/tests/test_allocation_limits.das create mode 100644 modules/dasMeshOptimizer/tests/test_meshoptimizer.das create mode 100644 modules/dasStbImage/README.md create mode 100644 modules/dasStbImage/src/stb_dxt.h create mode 100644 modules/dasStbImage/src/stb_dxt.version create mode 100644 modules/dasStbImage/stbimage/texture_blocks.das create mode 100644 modules/dasStbImage/tests/test_allocation_limits.das create mode 100644 modules/dasStbImage/tests/test_texture_blocks.das create mode 100644 tests/dasGLTF/test_gltf_atmosphere.das create mode 100644 tests/dasGLTF/test_gltf_processed.das diff --git a/CMakeLists.txt b/CMakeLists.txt index b78cecc45a..ff61f48079 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,7 @@ option(DAS_IMGUI_DISABLED "Disable dasImgui (Dear ImGui bindings + widget DSL; n option(DAS_VULKAN_DISABLED "Disable dasVulkan (Vulkan bindings generated from vk.xml; headers + volk vendored, no SDK needed)" OFF) option(DAS_AUDIO_DISABLED "Disable dasAudio (Miniaudio sound library)" OFF) option(DAS_STDDLG_DISABLED "Disable dasStdDlg (File new,open,save etc dialogs)" OFF) +option(DAS_MESHOPTIMIZER_DISABLED "Disable dasMeshOptimizer (mesh processing and buffer codecs)" OFF) option(DAS_STBIMAGE_DISABLED "Disable dasStbImage (StbImage bindings, image loading and saving)" OFF) option(DAS_METAL_DISABLED "Disable dasMetal (native Metal compute binding, Apple only)" OFF) option(DAS_ACCELERATE_DISABLED "Disable dasAccelerate (Accelerate BLAS binding — AMX/SME, Apple only)" OFF) diff --git a/daslib/ARCHITECTURE.md b/daslib/ARCHITECTURE.md index 73a4e72880..2c5ecd2ae5 100644 --- a/daslib/ARCHITECTURE.md +++ b/daslib/ARCHITECTURE.md @@ -285,3 +285,11 @@ Five companions carry a concern each; a section number is unique across all six hook by field name, so a C++ side back on `const` leaves daslang writing through a const reference with every suite green. `progArgs` (the program's `options`), `simulate` and the `aot*` hooks stay const on both sides. + +## 40. Package release symbols {#package-release-symbols} + +`daspkg.das:release_include_symbols` sets `ReleaseSpec.include_symbols`. +`utils/daspkg/commands.das` consumes that field: the native release copies Windows +PDBs, while `cmd_release_wasm` passes it to `release_one_wasm_app` to retain DWARF, +logical frames and the emcc symbol map. The manifest contract and these release +implementations describe the same option. diff --git a/daslib/daspkg.das b/daslib/daspkg.das index 0217c6739a..2fc149b86b 100644 --- a/daslib/daspkg.das +++ b/daslib/daspkg.das @@ -159,9 +159,7 @@ def release_requires_jit() { _release_spec.requires_jit = true } -//! Ship debug symbols for every shipped binary into `/symbols/`, so a minidump from the -//! deployed tree resolves (`cdb -y \symbols`). Opt-in: symbols dwarf the payload (daslang's -//! runtime PDBs are ~320 MB). Windows `.pdb` only; see `skills/daspkg.md`. +[arch(at="ARCHITECTURE.md#package-release-symbols")] def release_include_symbols() { _release_spec.include_symbols = true } diff --git a/daslib/fio.das b/daslib/fio.das index b8a1222c2c..311a99570c 100644 --- a/daslib/fio.das +++ b/daslib/fio.das @@ -69,12 +69,12 @@ def fload(file : file; size : int64; blk : block<(data : array) : void>) [generic] def fload(f : file; buf : auto(BufType) -const) { - var dfh : df_header + var dfh : df_header // nolint:LINT003,LINT019 - In generic instantiations, _builtin_read writes this raw output buffer. let r1 = _builtin_read(f, dfh, typeinfo sizeof(type)) if (r1 < 0 || dfh.magic != df_magic || dfh.size < 0) return false var loaded = false _builtin_load(f, int64(dfh.size)) $(data : array) { - if (length(data) != 0) { + if (!empty(data)) { binary_load(buf, data) loaded = true } diff --git a/doc/reflections/das2rst.das b/doc/reflections/das2rst.das index 56d6c288b5..27fab01e49 100644 --- a/doc/reflections/das2rst.das +++ b/doc/reflections/das2rst.das @@ -113,6 +113,7 @@ require daslib/remove_call_args require daslib/stringify require peg/peg require gltf/gltf_boost +require gltf/gltf_processed require daslib/temp_strings require daslib/type_traits require daslib/typemacro_boost @@ -130,6 +131,7 @@ require openai/openai_vision require pugixml/PUGIXML_boost require stbimage/stbimage_boost require stbimage/stbimage_ttf +require meshoptimizer require audio/audio_boost require audio/audio_wav require audio/audio_record @@ -1574,11 +1576,23 @@ def document_module_stbimage(_root : string) { group_by_regex("Write to memory", mod, %regex~(stbi_write_png_to_memory|stbi_write_bmp_to_memory|stbi_write_tga_to_memory|stbi_write_jpg_to_memory)$%%), group_by_regex("Write settings", mod, %regex~(stbi_flip_vertically_on_write|stbi_write_set_.*|stbi_write_get_.*)$%%), group_by_regex("Image resizing", mod, %regex~(stbir_resize|stbir_resize_uint8_srgb|stbir_resize_uint8_linear|stbir_resize_float_linear)$%%), + group_by_regex("Texture block compression", mod, %regex~stb_compress_blocks$%%), group_by_regex("Animated PNG (APNG) writer", mod, %regex~stbi_apng_(begin|frame|end|dropped)$%%) ) document("Image loading, writing, and resizing (stb_image)", mod, "stbimage.rst", groups) } +def document_module_meshoptimizer(_root : string) { + var mod = get_module("meshoptimizer") + var groups <- array( + group_by_regex("Vertex remapping", mod, %regex~meshopt_(generate_remap|remap_vertices|remap_indices)$%%), + group_by_regex("Mesh optimization", mod, %regex~meshopt_optimize_(cache|fetch)$%%), + group_by_regex("Vertex codec", mod, %regex~meshopt_(encode|decode)_vertices$%%), + group_by_regex("Index codec", mod, %regex~meshopt_(encode|decode)_indices$%%) + ) + document("Mesh remapping, optimization, and buffer codecs (meshoptimizer)", mod, "meshoptimizer.rst", groups) +} + def document_module_raster(_root : string) { var mod = get_module("raster") var groups <- array( @@ -1915,6 +1929,26 @@ def document_module_gltf(_root : string) { documents("glTF 2.0 loader (dasGLTF): GLB/.gltf parse, accessor decode, node hierarchy, skinning and keyframe animation into a backend-neutral scene", mod, "gltf.rst", groups) } +def document_module_texture_blocks(_root : string) { + var mod = find_module("texture_blocks") + var groups <- array( + group_by_regex("Texture types", mod, %regex~.*(BlockMip|BlockTexture)$%%), + group_by_regex("Compression and caching", mod, %regex~.*(block_texture_key|compress_block_texture|cached_block_texture)$%%), + group_by_regex("Validation and decoding", mod, %regex~.*(block_texture_valid|decode_block_mip)$%%) + ) + document("Portable BC texture compression, mip generation, caching, validation, and decoding", mod, "texture_blocks.rst", groups) +} + +def document_module_gltf_processed(_root : string) { + var mod = find_module("gltf_processed") + var groups <- array( + group_by_regex("Processing types", mod, %regex~.*(ProcessingProfile|ProcessedPrimitive|ProcessedMesh|ProcessedAsset|ProcessedCatalogEntry|ProcessedCatalog|ProcessedManifest)$%%), + group_by_regex("Validation and persistence", mod, %regex~.*(processed_valid|save_processed|load_processed|load_and_process_gltf)$%%), + group_by_regex("Geometry processing", mod, %regex~.*(processed_stride|processed_vertex_bytes|processed_indices|process_primitive|process_gltf|unpack_processed_geometry)$%%) + ) + document("Backend-neutral glTF geometry and texture preprocessing, validation, and persistence", mod, "gltf_processed.rst", groups) +} + // das2rst compiles tuned libraries to inspect their source API. The explicit // documentation context keeps all [tune] transforms and policy machinery inert. [export] @@ -1935,6 +1969,7 @@ def main { // nolint:STYLE038 — flat one-call-per-documented-module dispatch document_module_dashv(root) document_module_openai(root) document_module_stbimage(root) + document_module_meshoptimizer(root) document_module_raster(root) document_module_stbtruetype(root) document_module_uriparser(root) @@ -1962,6 +1997,8 @@ def main { // nolint:STYLE038 — flat one-call-per-documented-module dispatch // document dasPEG module document_module_peg(root) document_module_gltf(root) + document_module_texture_blocks(root) + document_module_gltf_processed(root) // document dasSpirv reflection module document_module_spirv(root) // document the dasLLAMA facade module (the engine modules behind it stay undocumented by design) diff --git a/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_decode_indices-0xc16e60c000f53bf4.rst b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_decode_indices-0xc16e60c000f53bf4.rst new file mode 100644 index 0000000000..bd4c63c278 --- /dev/null +++ b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_decode_indices-0xc16e60c000f53bf4.rst @@ -0,0 +1 @@ +Decode exactly ``count`` triangle-list indices into ``output`` and return true on success. ``count`` must be divisible by three; invalid codec data returns false and clears ``output``. diff --git a/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_decode_vertices-0x25019ef686d88461.rst b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_decode_vertices-0x25019ef686d88461.rst new file mode 100644 index 0000000000..1d108b208f --- /dev/null +++ b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_decode_vertices-0x25019ef686d88461.rst @@ -0,0 +1 @@ +Decode ``count`` packed vertex records of ``stride`` bytes into ``output`` and return true on success. Invalid codec data returns false and clears ``output``; invalid stride, oversized output, or aliasing ``encoded`` raises an error. diff --git a/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_encode_indices-0xcebc111796f37b7c.rst b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_encode_indices-0xcebc111796f37b7c.rst new file mode 100644 index 0000000000..ab43ddf14c --- /dev/null +++ b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_encode_indices-0xcebc111796f37b7c.rst @@ -0,0 +1 @@ +Encode triangle-list ``indices`` with the meshoptimizer index codec and resize ``output`` to the encoded byte count. Every index must be less than the supplied vertex ``count``. Triangle order and winding are preserved, but the three indices within each triangle may be cyclically rotated. diff --git a/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_encode_vertices-0xe1ad9380a82a899d.rst b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_encode_vertices-0xe1ad9380a82a899d.rst new file mode 100644 index 0000000000..3454f0314f --- /dev/null +++ b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_encode_vertices-0xe1ad9380a82a899d.rst @@ -0,0 +1 @@ +Encode packed vertex records into the meshoptimizer vertex codec and resize ``output`` to the encoded byte count. ``stride`` must be nonzero, 4-byte aligned, at most 256 bytes, and divide the input size; ``output`` must not alias ``vertices``. diff --git a/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_generate_remap-0xdbd2de7ee479a3c2.rst b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_generate_remap-0xdbd2de7ee479a3c2.rst new file mode 100644 index 0000000000..2bea956176 --- /dev/null +++ b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_generate_remap-0xdbd2de7ee479a3c2.rst @@ -0,0 +1 @@ +Build a vertex remap table from triangle-list ``indices`` and packed ``vertices``, returning the number of unique vertices. ``stride`` must be nonzero, 4-byte aligned, at most 256 bytes, and divide the vertex byte count; every index must be in range, and ``remap`` must not alias ``indices``. diff --git a/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_optimize_cache-0x9f5637f748a14874.rst b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_optimize_cache-0x9f5637f748a14874.rst new file mode 100644 index 0000000000..9e8d50eef3 --- /dev/null +++ b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_optimize_cache-0x9f5637f748a14874.rst @@ -0,0 +1 @@ +Reorder triangle-list ``indices`` in place to improve post-transform vertex-cache locality while preserving each triangle's orientation. Every index must be less than ``count``. diff --git a/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_optimize_fetch-0xcd80344cb34f875f.rst b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_optimize_fetch-0xcd80344cb34f875f.rst new file mode 100644 index 0000000000..dcd120663c --- /dev/null +++ b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_optimize_fetch-0xcd80344cb34f875f.rst @@ -0,0 +1 @@ +Reorder ``indices`` and packed ``vertices`` in place for sequential vertex fetch, discard unreferenced vertices, and return the resulting vertex count. The vertex stride and all triangle-list indices are validated before either array is modified. diff --git a/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_remap_indices-0x69706c78732a40b2.rst b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_remap_indices-0x69706c78732a40b2.rst new file mode 100644 index 0000000000..214d172bc5 --- /dev/null +++ b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_remap_indices-0x69706c78732a40b2.rst @@ -0,0 +1 @@ +Apply a vertex remap table to triangle-list ``indices`` and resize ``output`` to the index count. Every input index must address the remap table, and ``output`` must not alias ``remap``. diff --git a/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_remap_vertices-0xe33b3a97b651e4e8.rst b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_remap_vertices-0xe33b3a97b651e4e8.rst new file mode 100644 index 0000000000..3240630ba1 --- /dev/null +++ b/doc/source/stdlib/handmade/function-meshoptimizer-meshopt_remap_vertices-0xe33b3a97b651e4e8.rst @@ -0,0 +1 @@ +Apply ``remap`` to packed ``vertices`` and resize ``output`` to ``count * stride`` bytes. The remap table must contain one entry per input vertex, each entry must be less than ``count`` or the unused value ``0xffffffff``, and ``output`` must not alias ``vertices``. diff --git a/doc/source/stdlib/handmade/function-stbimage-stb_compress_blocks-0xcc27189d5d081eae.rst b/doc/source/stdlib/handmade/function-stbimage-stb_compress_blocks-0xcc27189d5d081eae.rst new file mode 100644 index 0000000000..24dd4645cb --- /dev/null +++ b/doc/source/stdlib/handmade/function-stbimage-stb_compress_blocks-0xcc27189d5d081eae.rst @@ -0,0 +1 @@ +Compress one tightly packed RGBA8 image into GPU texture blocks, replicating edge pixels when a dimension is not divisible by four. ``format`` selects BC1 (0), BC3 (1), BC4 from red (2), or BC5 from red and green (3); dimensions must be 1 through 32768, ``pixels`` must contain exactly ``width * height * 4`` bytes, and ``output`` must not alias ``pixels``. diff --git a/doc/source/stdlib/handmade/module-gltf_processed.rst b/doc/source/stdlib/handmade/module-gltf_processed.rst new file mode 100644 index 0000000000..588915f169 --- /dev/null +++ b/doc/source/stdlib/handmade/module-gltf_processed.rst @@ -0,0 +1 @@ +Backend-neutral glTF preprocessing that packs and optionally encodes geometry, compresses textures, and persists validated monolithic or split-file assets. diff --git a/doc/source/stdlib/handmade/module-meshoptimizer.rst b/doc/source/stdlib/handmade/module-meshoptimizer.rst new file mode 100644 index 0000000000..edbc51d652 --- /dev/null +++ b/doc/source/stdlib/handmade/module-meshoptimizer.rst @@ -0,0 +1 @@ +Checked array bindings for meshoptimizer 1.2 vertex remapping, vertex-cache and vertex-fetch optimization, and vertex and index buffer codecs. The API accepts triangle-list ``uint`` indices and packed vertex bytes with a nonzero, 4-byte-aligned stride of at most 256 bytes. diff --git a/doc/source/stdlib/handmade/module-texture_blocks.rst b/doc/source/stdlib/handmade/module-texture_blocks.rst new file mode 100644 index 0000000000..1b55952575 --- /dev/null +++ b/doc/source/stdlib/handmade/module-texture_blocks.rst @@ -0,0 +1 @@ +Portable BC1, BC3, BC4, and BC5 texture compression with complete mip chains, deterministic cache keys, serialized validation, and RGBA8 decoding. diff --git a/doc/source/stdlib/sec_media.rst b/doc/source/stdlib/sec_media.rst index 7617552267..a09daafdb7 100644 --- a/doc/source/stdlib/sec_media.rst +++ b/doc/source/stdlib/sec_media.rst @@ -14,3 +14,6 @@ Image loading, saving, resizing, pixel manipulation, TrueType font rendering, an generated/stbtruetype.rst generated/stbimage_ttf.rst generated/gltf.rst + generated/meshoptimizer.rst + generated/texture_blocks.rst + generated/gltf_processed.rst diff --git a/modules/dasAudio/ARCHITECTURE.md b/modules/dasAudio/ARCHITECTURE.md index 45186c680b..c04e315241 100644 --- a/modules/dasAudio/ARCHITECTURE.md +++ b/modules/dasAudio/ARCHITECTURE.md @@ -3,6 +3,25 @@ **Who reads this: me.** Durable facts about why the audio system is shaped the way it is - readable cold, no history, no PR numbers. +## Threaded WebAssembly output + +The AudioWorklet only consumes float PCM from a preallocated single-producer, +single-consumer ring and zero-fills an underrun. A regular pthread runs `mix_audio`, +including command-stream draining, decoder allocation and daslang context locking. +The worker produces 128-frame blocks into a 512-frame ring (about 10.7 ms at 48 kHz). +It sleeps briefly when the ring is full; the worklet never waits for it. + +This separation is required for correctness: an Emscripten AudioWorklet is a Wasm +Worker, and the hybrid runtime can initialize it with no pthread pointer. C++ mutex +ownership then sees thread ID zero, so a mutex acquired by that worklet need not +exclude a main-thread producer. Draining `Stream` there can race with `Stream::push`. +Do not put the command queue, allocator, context locks or script execution back in +that callback even when a particular browser/toolchain appears to tolerate it. + +Start the ring/producer before starting the device. On teardown, stop/join the +producer, stop the device, then free the ring and mixer context. Native, null-device +and single-threaded WASM backends retain the direct callback path. + ## The audio callback must never wait on a game thread `data_callback` (`src/dasAudio.cpp`) runs on miniaudio's realtime thread and has a buffer diff --git a/modules/dasAudio/CMakeLists.txt b/modules/dasAudio/CMakeLists.txt index fb41865186..4700407827 100644 --- a/modules/dasAudio/CMakeLists.txt +++ b/modules/dasAudio/CMakeLists.txt @@ -66,7 +66,7 @@ IF ((NOT DAS_AUDIO_INCLUDED) AND ((NOT ${DAS_AUDIO_DISABLED}) OR (NOT DEFINED DA SETUP_AUDIO(dasModuleAudio) # Threaded web build: drive miniaudio's emscripten backend via AudioWorklet - # (mixer callback on the dedicated audio rendering thread) instead of the + # (PCM consumption on the audio rendering thread, mixing on a pthread) instead of the # main-thread ScriptProcessorNode. Needs the worklet emcc link flags # (-sAUDIO_WORKLET=1 -sWASM_WORKERS=1, set in web/CMakeLists.txt) + the # non-blocking ma_device_init patch (patches/miniaudio_memory64.cmake), so it diff --git a/modules/dasAudio/src/dasAudio.cpp b/modules/dasAudio/src/dasAudio.cpp index fc5dfa5e3c..a1232c18b9 100644 --- a/modules/dasAudio/src/dasAudio.cpp +++ b/modules/dasAudio/src/dasAudio.cpp @@ -6,6 +6,8 @@ #include "daScript/simulate/bind_enum.h" #include +#include +#include // include vorbis extras before miniaudio #define STB_VORBIS_HEADER_ONLY @@ -308,7 +310,7 @@ void on_error_log ( void * , ma_uint32 level, const char * message ) { } } -void data_callback(ma_device*, void* pOutput, const void*, ma_uint32 frameCount) { +static void mix_audio(void* pOutput, ma_uint32 frameCount) { float fdt = 1.0f / float(g_rate); Array buffer; array_mark_locked(buffer, pOutput, frameCount * g_channels); @@ -324,6 +326,70 @@ void data_callback(ma_device*, void* pOutput, const void*, ma_uint32 frameCount) daScriptEnvironment::setBound(saved); } +#if defined(__EMSCRIPTEN__) && defined(__EMSCRIPTEN_PTHREADS__) +static ma_pcm_rb g_playback_rb; +static std::thread g_playback_thread; +static std::atomic g_playback_running { false }; +static bool g_playback_buffered = false; + +static bool start_playback_worker() { + if (ma_pcm_rb_init(ma_format_f32, (ma_uint32)g_channels, 512, nullptr, nullptr, &g_playback_rb) != MA_SUCCESS) return false; + g_playback_running.store(true, std::memory_order_release); + try { + g_playback_thread = std::thread([] { + while (g_playback_running.load(std::memory_order_acquire)) { + ma_uint32 count = 128; + void * output = nullptr; + if (ma_pcm_rb_acquire_write(&g_playback_rb, &count, &output) != MA_SUCCESS || !count) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + continue; + } + memset(output, 0, (size_t)count * g_channels * sizeof(float)); + mix_audio(output, count); + ma_pcm_rb_commit_write(&g_playback_rb, count); + } + }); + } catch (...) { + g_playback_running.store(false, std::memory_order_release); + ma_pcm_rb_uninit(&g_playback_rb); + return false; + } + g_playback_buffered = true; + return true; +} + +static void stop_playback_worker() { + g_playback_running.store(false, std::memory_order_release); + if (g_playback_thread.joinable()) g_playback_thread.join(); +} + +static void read_playback_ring(void * output, ma_uint32 frameCount) { + float * destination = (float *)output; + while (frameCount) { + ma_uint32 count = frameCount; + void * input = nullptr; + if (ma_pcm_rb_acquire_read(&g_playback_rb, &count, &input) != MA_SUCCESS || !count) { + memset(destination, 0, (size_t)frameCount * g_channels * sizeof(float)); + return; + } + memcpy(destination, input, (size_t)count * g_channels * sizeof(float)); + ma_pcm_rb_commit_read(&g_playback_rb, count); + destination += (size_t)count * g_channels; + frameCount -= count; + } +} +#endif + +void data_callback(ma_device*, void* pOutput, const void*, ma_uint32 frameCount) { +#if defined(__EMSCRIPTEN__) && defined(__EMSCRIPTEN_PTHREADS__) + if (g_playback_buffered) { + read_playback_ring(pOutput, frameCount); + return; + } +#endif + mix_audio(pOutput, frameCount); +} + Context & dasAudio_mixerContext ( Context * context, LineInfoArg * at ) { if ( !g_mixer_context ) context->throw_error_at(at,"sound mixer is not initialized"); return *g_mixer_context; @@ -371,9 +437,22 @@ bool dasAudio_init ( TFunc>,int32_t,int32_t,float> g_mixer_context->verySafeContext = false; g_mixer_function = mixer; g_mixer_env = daScriptEnvironment::getBound(); +#if defined(__EMSCRIPTEN__) && defined(__EMSCRIPTEN_PTHREADS__) + if (g_device.pContext->backend == ma_backend_webaudio && !start_playback_worker()) { + ma_device_uninit(&g_device); + g_mixer_context.reset(); + return false; + } +#endif if ( ma_device_start(&g_device) != MA_SUCCESS ) { +#if defined(__EMSCRIPTEN__) && defined(__EMSCRIPTEN_PTHREADS__) + stop_playback_worker(); +#endif ma_device_uninit(&g_device); if ( g_null_context_inited ) { ma_context_uninit(&g_null_context); g_null_context_inited = false; } +#if defined(__EMSCRIPTEN__) && defined(__EMSCRIPTEN_PTHREADS__) + if (g_playback_buffered) { ma_pcm_rb_uninit(&g_playback_rb); g_playback_buffered = false; } +#endif g_mixer_context.reset(); return false; } @@ -413,7 +492,13 @@ static bool ensure_capture_context () { void dasAudio_finalize ( void ) { if ( g_mixer_initialized ) { +#if defined(__EMSCRIPTEN__) && defined(__EMSCRIPTEN_PTHREADS__) + stop_playback_worker(); +#endif ma_device_uninit(&g_device); +#if defined(__EMSCRIPTEN__) && defined(__EMSCRIPTEN_PTHREADS__) + if (g_playback_buffered) { ma_pcm_rb_uninit(&g_playback_rb); g_playback_buffered = false; } +#endif g_mixer_context.reset(); g_mixer_initialized = false; } diff --git a/modules/dasGLTF/.das_module b/modules/dasGLTF/.das_module index 0ce5c1556a..1ec7c0506a 100644 --- a/modules/dasGLTF/.das_module +++ b/modules/dasGLTF/.das_module @@ -6,7 +6,7 @@ def initialize(project_path : string) { // gltf_gl / gltf_pbr are the opt-in OpenGL adapters (they require dasOpenGL); registering their // paths here only makes `require gltf/gltf_gl` resolvable — the neutral core never pulls them in. let gltf_paths = ["gltf_types", "gltf_accessor", "gltf_parse", "gltf_scene", "gltf_boost", - "gltf_gl", "gltf_pbr", "gltf_pbr_common"] + "gltf_gl", "gltf_atmosphere", "gltf_pbr", "gltf_pbr_common", "gltf_processed", "gltf_processed_gl"] for (path in gltf_paths) { register_native_path("gltf", "{path}", "{project_path}/gltf/{path}.das") } diff --git a/modules/dasGLTF/ARCHITECTURE.md b/modules/dasGLTF/ARCHITECTURE.md new file mode 100644 index 0000000000..2467531557 --- /dev/null +++ b/modules/dasGLTF/ARCHITECTURE.md @@ -0,0 +1,26 @@ +# dasGLTF architecture + +## 1. Atmospheric transmittance {#atmosphere-transmittance} + +The height-density integral uses a primitive continuous at the layer top, where +constant density meets exponential falloff. Rays crossing that boundary therefore +retain continuous transmittance. + +## 2. Directional shadow filtering {#directional-shadow-filtering} + +Directional shadow gradients are evaluated before divergent bounds checks. Shadow +coordinates reconstruct the geometric receiver plane independently of shading normals. +Filtering interpolates depth-comparison results, rather than raw depths: a 4-by-4 +nearest-sample footprint combines the weights of a 3-by-3 bilinear comparison kernel. + +## 3. Processed vertex portability {#processed-vertex-portability} + +UV packing uses scalar float16 conversion so CPU processing does not depend on +native half-vector casts. + +## 4. Compressed texture capability {#compressed-texture-capability} + +When the reported compressed-format list omits S3TC formats, upload checks S3TC +extension support. Its sRGB variants also require core or extension sRGB support. +Each mip dimension is block-aligned or one or two pixels; other dimensions cause +CPU decoding and uncompressed upload for the entire texture. diff --git a/modules/dasGLTF/README.md b/modules/dasGLTF/README.md index eb52e62afe..007d5f27d0 100644 --- a/modules/dasGLTF/README.md +++ b/modules/dasGLTF/README.md @@ -77,6 +77,28 @@ gltf_pbr_render(renderer, model, scene, view, proj, camPos, GltfPbrLight(direction = float3(0.3, 0.7, 0.5), intensity = 3.0)) ``` +## Optional directional shadows + +`gltf_pbr_set_point_lights(renderer, lights)` configures up to eight unshadowed +local lights (`GltfPbrPointLight`: position, linear color/radiance, radius). +They use the same PBR material response with a smooth finite-radius falloff. +An empty array clears them; existing callers default to no local lights. + +`gltf_pbr_render_depth(renderer, model, scene, root, light_vp)` renders the same +skinned/rigid transforms into the caller's bound depth framebuffer. Alpha-masked +materials retain their cutouts; blended primitives are skipped. The depth program +is created lazily. The caller owns framebuffer, viewport, depth state, and clear. + +`gltf_pbr_set_shadow(renderer, depth_tex, light_vp, resolution, bias, strength)` +enables bilinear PCF shadowing of direct light. Sixteen nearest depth comparisons +with subtexel weights reproduce a 3x3 kernel of bilinear PCF taps, keeping coverage +continuous across texel boundaries. Raw depth is never interpolated. Use a depth texture with compare +mode `GL_NONE`, nearest filtering, and clamp-to-edge wrapping. Texture unit 6 is +reserved for this sampler. Passing texture 0 disables shadows. The renderer owns +neither the texture nor framebuffer. Existing callers have shadows disabled. + +Render the depth pass before the lit pass, using the same transforms in both. + ## Examples - `examples/opengl/10_hello_gltf.das` - static PBR viewer (orbit camera); defaults to the shipped @@ -98,3 +120,102 @@ glTF extensions are not implemented. An asset whose `extensionsRequired` names a closed** (empty scene + error log, per spec) instead of decoding to degenerate geometry - e.g. Draco-compressed assets; recompress with `gltf-transform` / `gltfpack` to plain glTF. Optional `extensionsUsed`-only extensions load with a warning and their features are ignored. + +The PBR adapter also offers `gltf_pbr_render_normals` for an opaque/masked depth-normal +prepass. It shares the regular skinning, material-normal and alpha-cutoff paths. The +caller owns the framebuffer and depth texture; encoded world normals are written as +`normal * 0.5 + 0.5`. Blended materials are excluded. + +A caller-owned viewport-sized SSAO texture can be supplied through `GltfPbrRenderer.aoTex` +and `aoInverseSize`. Texture zero disables it. Visibility multiplies ambient/environment +lighting only, leaving direct lights and emission intact. The adapter does not own the AO +texture; the application owns its AO generation and depth-aware filtering. + +Directional PCF compares each sampled texel against the corresponding depth on the +geometric receiver plane, reconstructed from shadow-coordinate derivatives. This +prevents sloped surfaces from shadowing themselves across the filter footprint as +the camera or fitted shadow coverage changes. The remaining small depth bias handles +rasterization precision; it is not used to hide the entire filter footprint. + +`gltf_pbr_render_silhouette` draws opaque/masked models with a flat framebuffer color, +using the normal skinning and alpha-cutoff paths. The caller owns the depth comparison +and depth-write mask. For an occluded-character indicator, draw against environment depth +with `GL_GREATER` and depth writes disabled, then draw the normal visible character. +This ordering avoids tinting visible portions through the character's own overlapping parts. + +`upload_gltf_geometry(scene, texture_owner)` uploads independent geometry buffers +while borrowing the owner's texture handles. The texture owner must outlive all +borrowers; deleting a borrower releases only its geometry and handle arrays. +It does not require decoded images on the supplied scene. This is useful for +spatial batches without replicating each asset's decoded images and GPU textures. + +The GL renderer also accepts `atmosphere` and `backgroundTint` settings. Atmosphere +uses the shared `gltf/gltf_atmosphere` ray integral; zero densities disable it. +Background tint's RGB multiplies luminance, with its W controlling the blend. +Local shadow atlases are caller-owned: `localShadowTex`, `localShadowSize` (face +resolution, row count), and `pointShadow` entries (row+1, strength, near, far). +Each row contains +X, -X, +Y, -Y, +Z, -Z depth faces; zero strength disables an entry. + +## Processed glTF assets + +`gltf/gltf_processed` supplies a shared daslang processing pipeline backed by +`dasMeshOptimizer` and `stbimage/texture_blocks`. Native callers may process regular +glTF/GLB on load; packaged applications load prepared assets directly. + +- `.das_gltf`: JSON manifest referencing a `.das_mesh` payload and shared `.das_tex` files. +- `.das_glb`: self-contained binary asset, including its compressed images. + +These are versioned daslang runtime formats, not standard glTF files. Node names, +hierarchies, materials, skins, animation data and bounds are retained. Geometry +contains explicit attribute flags/strides and meshoptimizer-encoded buffers. +Positions stay float32. Normals/tangents use signed 10-bit components; UVs use +binary16; joints and weights use 16-bit components. Optional UV1/vertex colour +retention is part of `ProcessingProfile`. The default PBR profile uses 20 bytes for +textured rigid vertices and 36 for textured skinned vertices, adding tangents only +for normal-mapped materials. No simplification or LOD generation is performed. + +Processing deduplicates vertices, improves cache/fetch order, and encodes buffers. +Translucent primitives preserve triangle order. `gltf/gltf_processed_gl` decodes +directly to the packed GPU layout and chooses uint16 indices when possible. +`unpack_processed_geometry` supports CPU-side mesh assembly; ordinary rendering +keeps vertices in the packed GPU layout. + +```sh +bin/daslang -no-module-cache modules/dasGLTF/tools/convert.das -- \ + --root path/to/source-assets \ + --out path/to/processed-assets +``` + +Explicit input files can follow the options. `--self-contained` produces +`.das_glb`; `--force` rewrites assets; `--cache DIR` changes the texture cache. +The default cache is `.jitted_scripts/assets/textures`, relative to the working +directory. Repeated conversion checks asset keys and leaves unchanged outputs +alone. `asset-build.json` records the converted catalog and can be included in +application build fingerprints. Conversion never rewrites the source glTF files. + +Texture cache identity includes decoded content, dimensions, colour space, format +and codec-layer version. BC1 handles opaque images; BC3 preserves image alpha. +Full mip chains are generated before compression. A `GltfGlTexturePool`, scoped +to one GL context, shares uploaded images across models with equivalent sampler +settings. Delete its models before deleting the pool. Unsupported block formats +or incompatible dimensions use an RGBA8 decode fallback; RGB565 is not used. + +The binary serializer fixes accompanying this pipeline correct array element +allocation stride and release the writer's temporary callback buffer. Regression +coverage includes nested arrays with explicit deletion, both asset containers, +node/skin/animation preservation, bounds, transparent triangle order, and codecs. + +### Optional screen-space material inputs + +`gltf_pbr_render_normals` accepts `pack_roughness=true` to put perceptual roughness +in normal-buffer alpha, and `material_buffer=true` to draw linear albedo plus +metallic into RGBA instead. These paths share skinning and alpha-mask handling with +ordinary rendering. Existing calls retain opaque normal-buffer alpha. + +`GltfPbrRenderer.materialScale` controls roughness/metallic multipliers (default 1). +`materialWetness` optionally interprets the metal/rough texture's unused red channel +as a wetness mask, blending roughness toward .16; its default zero preserves glTF. +Callers must enable this only for textures authored with that mask convention. +`reflectionAmount` adds reflection-only environment lighting, independent of the +existing image-based diffuse/ambient amount. It defaults to zero and uses the +caller-owned environment texture set through `gltf_pbr_set_environment`. diff --git a/modules/dasGLTF/gltf/gltf_atmosphere.das b/modules/dasGLTF/gltf/gltf_atmosphere.das new file mode 100644 index 0000000000..35c8dcbb62 --- /dev/null +++ b/modules/dasGLTF/gltf/gltf_atmosphere.das @@ -0,0 +1,22 @@ +options gen2 +module gltf_atmosphere shared public +require math +// Optical depth integrated along the ray. The lower layer is constant below +// its top and falls off exponentially above it; no screen-space depth approximation. +struct Atmosphere { + color : float3 + density, start, height_density, height_top, height_falloff : float +} +[arch(at="../ARCHITECTURE.md#atmosphere-transmittance")] +def atmosphere_transmittance(eye, point : float3; params : float4; falloff : float) : float { + let distance = length(point - eye) + let a = clamp((params.w - eye.y) * falloff, -40.0, 0.0) + let b = clamp((params.w - point.y) * falloff, -40.0, 0.0) + var average = exp(a) + if (abs(eye.y - point.y) > 0.001){ + let pa = eye.y < params.w ? eye.y - params.w - 1.0 / falloff : -exp(a) / falloff + let pb = point.y < params.w ? point.y - params.w - 1.0 / falloff : -exp(b) / falloff + average = (pb - pa) / (point.y - eye.y) + } + return exp(-max(distance - params.y, 0.0) * params.x - distance * max(average, 0.0) * params.z) +} diff --git a/modules/dasGLTF/gltf/gltf_gl.das b/modules/dasGLTF/gltf/gltf_gl.das index 79817586db..1ee55cb31e 100644 --- a/modules/dasGLTF/gltf/gltf_gl.das +++ b/modules/dasGLTF/gltf/gltf_gl.das @@ -32,16 +32,31 @@ struct private GltfGlVertex { struct GltfGlPrimitive { vao, vbo, ebo : uint + indexType : uint + missingAttributes : uint + vertexBytes, indexBytes : uint64 indexCount : int mode : uint // GL_TRIANGLES / GL_LINES / GL_POINTS material : int = -1 // GltfScene.materials index, -1 = default } +let GLTF_GL_ATTR_TANGENT = 1u << 2u +let GLTF_GL_ATTR_UV0 = 1u << 3u +let GLTF_GL_ATTR_UV1 = 1u << 4u +let GLTF_GL_ATTR_COLOR0 = 1u << 5u +let GLTF_GL_ATTR_JOINTS0 = 1u << 6u +let GLTF_GL_ATTR_WEIGHTS0 = 1u << 7u + struct GltfGlMesh { primitives : array } +struct GltfGlTextureEntry { texture : uint; references : int; bytes : uint64 } +struct GltfGlTexturePool { entries : table } struct GltfGlModel { + @do_not_delete texturePool : GltfGlTexturePool? + textureKeys : array + borrowsTextures : bool meshes : array // parallel to GltfScene.meshes textures : array // GL handle per GltfScene.textures entry (0 = missing) whiteTex : uint // 1x1 opaque white — base-color/metal-rough/occlusion/emissive default @@ -92,10 +107,8 @@ def private create_gl_texture(img : GltfImage; sampler : GltfSampler; srgb : boo var prev_align : int glGetIntegerv(GL_UNPACK_ALIGNMENT, safe_addr(prev_align)) glPixelStorei(GL_UNPACK_ALIGNMENT, 1) - unsafe { - glTexImage2D(GL_TEXTURE_2D, 0, internal, img.width, img.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, - addr(img.pixels[0])) - } + glTexImage2D(GL_TEXTURE_2D, 0, internal, img.width, img.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, + unsafe(addr(img.pixels[0]))) glPixelStorei(GL_UNPACK_ALIGNMENT, prev_align) glGenerateMipmap(GL_TEXTURE_2D) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, gl_wrap(sampler.wrapS)) @@ -112,10 +125,8 @@ def private create_solid_texture(r, g, b, a : uint) : uint { glGenTextures(1, safe_addr(tex)) glBindTexture(GL_TEXTURE_2D, tex) var px = [uint8(r), uint8(g), uint8(b), uint8(a)] - unsafe { - glTexImage2D(GL_TEXTURE_2D, 0, int(GL_RGBA8), 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, - addr(px[0])) - } + glTexImage2D(GL_TEXTURE_2D, 0, int(GL_RGBA8), 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, + unsafe(addr(px[0]))) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) @@ -158,6 +169,7 @@ def private upload_primitive(prim : GltfPrimitive) : GltfGlPrimitive { glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo) glBufferData(GL_ELEMENT_ARRAY_BUFFER, prim.indices, GL_STATIC_DRAW) } + glp.vertexBytes = uint64(long_length(verts)) * uint64(typeinfo sizeof(type)); glp.indexBytes = uint64(long_length(prim.indices)) * 4ul glBindVertexArray(0u) delete verts return glp @@ -165,6 +177,21 @@ def private upload_primitive(prim : GltfPrimitive) : GltfGlPrimitive { // ===== public API ===== +//! Upload geometry using textures owned by another model. The texture owner must outlive +//! the result. Batches retain independent buffer ownership, without duplicating images. +def upload_gltf_geometry(scene : GltfScene; texture_owner : GltfGlModel) : GltfGlModel { + var model = GltfGlModel(borrowsTextures = true, whiteTex = texture_owner.whiteTex, flatNormalTex = texture_owner.flatNormalTex) + model.textures <- clone(texture_owner.textures) + model.meshes |> reserve(length(model.meshes) + length(scene.meshes)) + for (mesh in scene.meshes){ + var glmesh : GltfGlMesh + glmesh.primitives |> reserve(length(mesh.primitives)) + for (prim in mesh.primitives){glmesh.primitives |> emplace(upload_primitive(prim))} + model.meshes |> emplace(glmesh) + } + return <- model +} + //! Upload a parsed GltfScene to GL: per-primitive VAO/VBO/EBO, one GL texture per glTF texture //! (sRGB / linear per usage, mipmapped, sampler wrap/filter), plus white / flat-normal defaults. //! Call with a current GL context. `delete` the returned model to release every GL object. @@ -237,8 +264,14 @@ def gltf_gl_resolve_tex(model : GltfGlModel; texIndex : int; fallback : uint) : //! textures first (gltf_pbr does this per node/material). def gltf_gl_draw_primitive(glp : GltfGlPrimitive) { glBindVertexArray(glp.vao) + if ((glp.missingAttributes & GLTF_GL_ATTR_TANGENT) != 0u) { glVertexAttrib4f(2u, 0.0, 0.0, 0.0, 1.0) } + if ((glp.missingAttributes & GLTF_GL_ATTR_UV0) != 0u) { glVertexAttrib4f(3u, 0.0, 0.0, 0.0, 1.0) } + if ((glp.missingAttributes & GLTF_GL_ATTR_UV1) != 0u) { glVertexAttrib4f(4u, 0.0, 0.0, 0.0, 1.0) } + if ((glp.missingAttributes & GLTF_GL_ATTR_COLOR0) != 0u) { glVertexAttrib4f(5u, 1.0, 1.0, 1.0, 1.0) } + if ((glp.missingAttributes & GLTF_GL_ATTR_JOINTS0) != 0u) { glVertexAttrib4f(6u, 0.0, 0.0, 0.0, 0.0) } + if ((glp.missingAttributes & GLTF_GL_ATTR_WEIGHTS0) != 0u) { glVertexAttrib4f(7u, 0.0, 0.0, 0.0, 0.0) } glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, glp.ebo) - glDrawElements(glp.mode, glp.indexCount, GL_UNSIGNED_INT, null) + glDrawElements(glp.mode, glp.indexCount, glp.indexType == 0u ? GL_UNSIGNED_INT : glp.indexType, null) } def finalize(var model : GltfGlModel) { @@ -252,18 +285,33 @@ def finalize(var model : GltfGlModel) { if (ebo != 0u) { glDeleteBuffers(1, safe_addr(ebo)) } } } - for (t in model.textures) { - var tt = t - if (tt != 0u) { glDeleteTextures(1, safe_addr(tt)) } - } - if (model.whiteTex != 0u) { - var w = model.whiteTex - glDeleteTextures(1, safe_addr(w)) - } - if (model.flatNormalTex != 0u) { - var f = model.flatNormalTex - glDeleteTextures(1, safe_addr(f)) + if (!model.borrowsTextures){ + if (model.texturePool != null){ + unsafe{for (key in model.textureKeys){if (key != 0ul && key_exists(model.texturePool.entries, key)){ + var entry & = model.texturePool.entries[key]; entry.references-- + if (entry.references == 0){var id = entry.texture; glDeleteTextures(1, safe_addr(id)); erase(model.texturePool.entries, key)} + }}} + }else{ + for (t in model.textures) { + var tt = t + if (tt != 0u) { glDeleteTextures(1, safe_addr(tt)) } + } + } + if (model.whiteTex != 0u) { + var w = model.whiteTex + glDeleteTextures(1, safe_addr(w)) + } + if (model.flatNormalTex != 0u) { + var f = model.flatNormalTex + glDeleteTextures(1, safe_addr(f)) + } } delete model.meshes - delete model.textures + delete model.textures; delete model.textureKeys; model.texturePool = null +} + +//! Pools belong to one GL context and must outlive their models. +def finalize(var pool : GltfGlTexturePool){ + for (entry in values(pool.entries)){var id = entry.texture; if (id != 0u){glDeleteTextures(1, safe_addr(id))}} + delete pool.entries } diff --git a/modules/dasGLTF/gltf/gltf_pbr.das b/modules/dasGLTF/gltf/gltf_pbr.das index fdde84a7cb..a881cbc630 100644 --- a/modules/dasGLTF/gltf/gltf_pbr.das +++ b/modules/dasGLTF/gltf/gltf_pbr.das @@ -13,6 +13,7 @@ require opengl/opengl_boost public require glsl/glsl_opengl public require daslib/safe_addr require math +require gltf/gltf_atmosphere public require daslib/math_boost // Max joints in the palette uniform. Desktop GL guarantees >= 1024 vec4 of vertex uniforms; 64 mat4 is @@ -80,6 +81,10 @@ def gltf_pbr_vs { } // ===== fragment stage ===== +var @uniform u_fog_color : float3 +var @uniform u_fog_params : float4 +var @uniform u_fog_falloff : float +var @uniform u_background_tint : float4 var @uniform u_cam_pos : float3 var @uniform u_light_dir : float3 // direction TOWARD the light, normalized var @uniform u_light_color : float3 // radiance (color * intensity) @@ -87,6 +92,10 @@ var @uniform u_ambient : float3 // constant ambient irradiance var @uniform u_base_color_factor : float4 var @uniform u_metallic_factor : float +var @uniform u_material_scale : float2 +var @uniform u_material_wetness : float +var @uniform u_normal_roughness : float +var @uniform u_reflection_amount : float var @uniform u_roughness_factor : float var @uniform u_emissive_factor : float3 var @uniform u_normal_scale : float @@ -105,8 +114,83 @@ var @uniform @stage = 5 u_env_tex : sampler2D var @uniform u_env_amount : float var @uniform u_env_max_lod : float +//! Regular depth sampling permits a color fallback while shadows are disabled. +var @uniform @stage = 6 u_shadow_tex : sampler2D +var @uniform u_shadow_vp : float4x4 +var @uniform u_shadow_texel : float2 +var @uniform u_shadow_bias : float +var @uniform u_shadow_strength : float + +let GLTF_MAX_POINT_LIGHTS = 8 +var @uniform @stage = 9 u_local_shadow_tex : sampler2D +var @uniform u_local_shadow_size : float2 +var @uniform u_point_shadow : float4[GLTF_MAX_POINT_LIGHTS] +var @uniform u_point_count : int +var @uniform u_point_position : float4[GLTF_MAX_POINT_LIGHTS] +var @uniform u_point_color : float4[GLTF_MAX_POINT_LIGHTS] + var @out o_color : float4 +[arch(at="../ARCHITECTURE.md#directional-shadow-filtering")] +def private shadow_visibility(world : float3; normal : float3) : float { + if (u_shadow_strength <= 0.0) { return 1.0 } + let light_pos = u_shadow_vp * float4(world, 1.0) + let p = light_pos.xyz / light_pos.w * 0.5 + float3(0.5) + let gradient = gltf_shadow_receiver_gradient(dFdx(p), dFdy(p)) + if (p.x <= 0.0 || p.x >= 1.0 || p.y <= 0.0 || p.y >= 1.0 || p.z <= 0.0 || p.z >= 1.0) { return 1.0 } + let bias = u_shadow_bias * (1.0 + 3.0 * (1.0 - max(dot(normal, normalize(u_light_dir)), 0.0))) + let texel_pos = p.xy / u_shadow_texel - float2(0.5) + let base = floor(texel_pos) + let fraction = texel_pos - base + let wx = gltf_shadow_pcf_weights(fraction.x) + let wy = gltf_shadow_pcf_weights(fraction.y) + var visible = 0.0 + for (y in range(4)) { + for (x in range(4)) { + let uv = (base + float2(float(x), float(y)) - float2(0.5)) * u_shadow_texel + let depth = texture(u_shadow_tex, uv).x + visible += (gltf_shadow_receiver_depth(p, uv, gradient, bias) <= depth ? 1.0 : 0.0) * wx[x] * wy[y] + } + } + return 1.0 - u_shadow_strength * (1.0 - visible) +} + +def private point_shadow_visibility(index : int; world, normal : float3) : float { + let settings = u_point_shadow[index] + if (settings.x < 0.5 || settings.y <= 0.0){return 1.0} + let delta = world + normal * 0.008 - u_point_position[index].xyz + let a = abs(delta); var face = 0; var xy = float2(0.0); var distance = a.x + if (a.x >= a.y && a.x >= a.z){ + face = delta.x >= 0.0 ? 0 : 1; xy = float2(delta.x >= 0.0 ? -delta.z : delta.z, -delta.y) + }elif (a.y >= a.z){ + face = delta.y >= 0.0 ? 2 : 3; distance = a.y; xy = float2(delta.x, delta.y >= 0.0 ? delta.z : -delta.z) + }else{ + face = delta.z >= 0.0 ? 4 : 5; distance = a.z; xy = float2(delta.z >= 0.0 ? delta.x : -delta.x, -delta.y) + } + let near = settings.z; let far = settings.w + let safe_distance = max(distance, 0.000001) + let p = float3(xy / safe_distance * 0.5 + float2(0.5), far / (far - near) - far * near / ((far - near) * safe_distance)) + let gradient = gltf_shadow_receiver_gradient(dFdx(p), dFdy(p)) + if (distance <= near || distance >= far){return 1.0} + let size = u_local_shadow_size.x; let base = floor(p.xy * size - float2(0.5)); let f = p.xy * size - float2(0.5) - base + let wx = gltf_shadow_pcf_weights(f.x); let wy = gltf_shadow_pcf_weights(f.y) + var visible = 0.0 + for (y in range(4)){for (x in range(4)){ + let uv = clamp((base + float2(float(x), float(y)) - float2(0.5)) / size, float2(0.5 / size), float2(1.0 - 0.5 / size)) + let atlas = (uv + float2(float(face), settings.x - 1.0)) / float2(6.0, u_local_shadow_size.y) + let depth = texture(u_local_shadow_tex, atlas).x + visible += (gltf_shadow_receiver_depth(p, uv, gradient, 0.00015) <= depth ? 1.0 : 0.0) * wx[x] * wy[y] + }} + return 1.0 - settings.y * (1.0 - visible) +} + +[fragment_program] +def gltf_pbr_depth_fs { + if (u_alpha_mode > 0.5 && u_alpha_mode < 1.5 && + texture(u_base_color_tex, v_uv0).w * u_base_color_factor.w < u_alpha_cutoff) { discard() } +} + + // The BRDF / normal-perturbation / skinning math lives in gltf/gltf_pbr_common — shared with the // dasVulkan renderer; this module only samples textures and feeds the values in. def private shade_normal(uv : float2) : float3 { @@ -114,6 +198,26 @@ def private shade_normal(uv : float2) : float3 { texture(u_normal_tex, uv).xyz, u_normal_scale) } +var @uniform @stage = 8 u_ssao_tex : sampler2D +var @uniform u_ssao_enabled : float +var @uniform u_ssao_inverse_size : float2 +def gltf_surface_roughness(mr : float4) : float { + return clamp(lerp(mr.y * u_roughness_factor * u_material_scale.x, .16, clamp(u_material_wetness * mr.x, 0.0, 1.0)), .04, 1.0) +} +[fragment_program] +def gltf_pbr_normal_fs { + let base = texture(u_base_color_tex, v_uv0) * u_base_color_factor + if (u_alpha_mode > 0.5 && u_alpha_mode < 1.5 && base.w < u_alpha_cutoff){discard()} + let roughness = gltf_surface_roughness(texture(u_metal_rough_tex, v_uv0)) + o_color = float4(shade_normal(v_uv0) * 0.5 + float3(0.5), u_normal_roughness > .5 ? roughness : 1.0) +} + +[fragment_program] +def gltf_pbr_material_fs { + let base = texture(u_base_color_tex, v_uv0) * u_base_color_factor + if (u_alpha_mode > .5 && u_alpha_mode < 1.5 && base.w < u_alpha_cutoff){discard()} + o_color = float4(base.xyz, clamp(texture(u_metal_rough_tex, v_uv0).z * u_metallic_factor * u_material_scale.y, 0.0, 1.0)) +} [fragment_program] def gltf_pbr_fs { let uv = v_uv0 @@ -123,8 +227,8 @@ def gltf_pbr_fs { discard() } let mr = texture(u_metal_rough_tex, uv) - let metallic = mr.z * u_metallic_factor - let roughness = clamp(mr.y * u_roughness_factor, 0.04, 1.0) + let metallic = clamp(mr.z * u_metallic_factor * u_material_scale.y, 0.0, 1.0) + let roughness = gltf_surface_roughness(mr) let occ = texture(u_occlusion_tex, uv).x let emissive = texture(u_emissive_tex, uv).xyz * u_emissive_factor @@ -144,19 +248,103 @@ def gltf_pbr_fs { gltf_occlusion_factor(occ, u_occlusion_strength)) * u_env_amount } - let linear = ambient + direct + emissive + if (u_reflection_amount > 0.0){ + let reflected = textureLod(u_env_tex, gltf_equirect_uv(gltf_reflect_dir(n, v)), roughness * u_env_max_lod).xyz + let f0 = lerp(float3(.04), albedo, float3(metallic)) + ambient += reflected * gltf_fresnel_roughness(max(dot(n, v), 0.0), f0, roughness) * u_reflection_amount * gltf_occlusion_factor(occ, u_occlusion_strength) + } + var local_light = float3(0.0) + for (i in range(GLTF_MAX_POINT_LIGHTS)) { + if (i < u_point_count) { + let delta = u_point_position[i].xyz - v_world_pos + let distance = max(length(delta), 0.001) + let visibility = point_shadow_visibility(i, v_world_pos, n) + if (distance < u_point_position[i].w){ + let falloff = max(0.0, 1.0 - distance / max(u_point_position[i].w, 0.001)) + let radiance = u_point_color[i].xyz * (falloff * falloff / (1.0 + distance * distance)) * visibility + local_light += gltf_pbr_direct(albedo, metallic, roughness, n, v, delta / distance, radiance) + } + } + } + if (u_ssao_enabled > 0.5){ambient *= texture(u_ssao_tex, gl_FragCoord.xy * u_ssao_inverse_size).x} + let linear = ambient + direct * shadow_visibility(v_world_pos, n) + local_light + emissive // no sRGB default framebuffer -> encode linear -> sRGB (approximate 2.2 gamma) - let out_rgb = gltf_srgb_encode(linear) + var out_rgb = gltf_srgb_encode(linear) + let grey = dot(out_rgb, float3(0.299, 0.587, 0.114)) + out_rgb = lerp(out_rgb, float3(grey) * u_background_tint.xyz, u_background_tint.w) + let transmission = atmosphere_transmittance(u_cam_pos, v_world_pos, u_fog_params, max(u_fog_falloff, 0.01)) + out_rgb = lerp(u_fog_color, out_rgb, transmission) let out_a = u_alpha_mode > 1.5 ? base.w : 1.0 o_color = float4(out_rgb, out_a) } +var @uniform u_silhouette_color : float4 +[fragment_program] +def gltf_pbr_silhouette_fs { + let base = texture(u_base_color_tex, v_uv0) * u_base_color_factor + if (u_alpha_mode > 0.5 && base.w < u_alpha_cutoff){discard()} + o_color = u_silhouette_color +} + // ===== host renderer ===== +[safe_when_uninitialized] struct GltfPbrRenderer { + materialWetness : float + materialScale : float2 = float2(1.0) + reflectionAmount : float + materialProgram : uint + atmosphere : Atmosphere + backgroundTint : float4 program : uint envTex : uint // mipmapped equirect HDR environment, 0 = none (flat ambient) envMaxLod : float + silhouetteProgram : uint + normalProgram : uint + aoTex : uint + aoInverseSize : float2 + depthProgram : uint + shadowTex : uint + shadowViewProj : float4x4 + shadowTexel : float2 + shadowBias : float + shadowStrength : float + localShadowTex : uint + localShadowSize : float2 + pointShadow : float4[GLTF_MAX_POINT_LIGHTS] + pointCount : int + pointPosition : float4[GLTF_MAX_POINT_LIGHTS] + pointColor : float4[GLTF_MAX_POINT_LIGHTS] +} + +struct GltfPbrPointLight { + castsShadow : bool + position : float3 + color : float3 + radius : float +} + +//! Small local-light set, useful for torches/spells. Colors are linear radiance. +//! Unshadowed unless the caller supplies the optional per-light depth atlas settings. +//! Passing an empty array clears the set. Excess lights are ignored after the first eight. +def gltf_pbr_set_point_lights(var r : GltfPbrRenderer; lights : array) { + r.pointCount = min(length(lights), GLTF_MAX_POINT_LIGHTS) + for (i in range(r.pointCount)) { + r.pointPosition[i] = float4(lights[i].position, max(lights[i].radius, 0.001)) + r.pointColor[i] = float4(max(lights[i].color, float3(0.0)), 0.0) + } +} + +//! Configure a caller-owned depth texture (compare mode NONE, nearest filtering). +//! Passing texture 0 disables shadows; the renderer never owns the map/FBO. +//! Shadowing affects direct illumination only, preserving ambient and emissive light. +def gltf_pbr_set_shadow(var r : GltfPbrRenderer; tex : uint; light_vp : float4x4; + resolution : int; bias : float = 0.0005; strength : float = 1.0) { + r.shadowTex = tex + r.shadowViewProj = light_vp + r.shadowTexel = float2(1.0 / float(max(resolution, 1))) + r.shadowBias = max(bias, 0.0) + r.shadowStrength = tex != 0u ? clamp(strength, 0.0, 1.0) : 0.0 } //! Attach a mipmapped equirect HDR environment (see gltf_gl_load_hdr_env); pass 0 to remove it. @@ -178,6 +366,10 @@ def finalize(var r : GltfPbrRenderer) { if (r.program != 0u) { glDeleteProgram(r.program) } + if (r.depthProgram != 0u) { glDeleteProgram(r.depthProgram) } + if (r.silhouetteProgram != 0u) { glDeleteProgram(r.silhouetteProgram) } + if (r.materialProgram != 0u){glDeleteProgram(r.materialProgram)} + if (r.normalProgram != 0u) { glDeleteProgram(r.normalProgram) } } def private bind_material(model : GltfGlModel; mat : GltfMaterial) { @@ -224,6 +416,88 @@ def private bind_skin_palette(scene : GltfScene; skinIndex : int; root : float4x } } +def private bind_node(scene : GltfScene; node : GltfNode; root : float4x4) { + if (node.skin >= 0 && node.skin < length(scene.skins)) { + bind_skin_palette(scene, node.skin, root) + u_skinned = 1.0 + u_model = identity4x4() // skinned path blends the (root-baked) palette; u_model is unused + u_normal_matrix = identity3x3() // unused in the skinned branch + } else { + u_skinned = 0.0 + u_model = root * node.world + u_normal_matrix = float3x3(transpose(inverse(u_model))) + } +} + +//! Render opaque/masked geometry into the caller's bound depth framebuffer. +//! Lazily creates the depth program; caller owns viewport, clear, depth and FBO state. +//! Blended surfaces do not cast solid shadows. +def gltf_pbr_render_depth(var r : GltfPbrRenderer; model : GltfGlModel; scene : GltfScene; + root, light_vp : float4x4) { + if (r.depthProgram == 0u) { r.depthProgram = create_shader_program(@@gltf_pbr_vs, @@gltf_pbr_depth_fs) } + glUseProgram(r.depthProgram) + u_viewProj = light_vp + for (node in scene.nodes) { + if (node.mesh < 0 || node.mesh >= length(model.meshes)) { continue } + bind_node(scene, node, root) + for (glp in model.meshes[node.mesh].primitives) { + if (glp.material >= 0 && glp.material < length(scene.materials)) { + let mat = scene.materials[glp.material] + if (mat.alphaMode == GltfAlphaMode.blend) { continue } + bind_material(model, mat) + } else { bind_material(model, GltfMaterial()) } + gltf_pbr_vs_bind_uniform(r.depthProgram) + gltf_pbr_depth_fs_bind_uniform(r.depthProgram) + gltf_gl_draw_primitive(glp) + } + } + glUseProgram(0u) +} + +//! Opaque depth/normal prepass using the same skinning, material normals and alpha masks. +def gltf_pbr_render_normals(var r : GltfPbrRenderer; model : GltfGlModel; scene : GltfScene; root, vp : float4x4; pack_roughness : bool = false; material_buffer : bool = false) { + if (r.normalProgram == 0u){r.normalProgram = create_shader_program(@@gltf_pbr_vs, @@gltf_pbr_normal_fs)} + if (material_buffer && r.materialProgram == 0u){r.materialProgram = create_shader_program(@@gltf_pbr_vs, @@gltf_pbr_material_fs)} + let program = material_buffer ? r.materialProgram : r.normalProgram + glUseProgram(program); u_viewProj = vp; u_material_scale = r.materialScale; u_material_wetness = r.materialWetness; u_normal_roughness = pack_roughness ? 1.0 : 0.0 + for (node in scene.nodes){ + if (node.mesh < 0 || node.mesh >= length(model.meshes)){continue} + bind_node(scene, node, root) + for (glp in model.meshes[node.mesh].primitives){ + if (glp.material >= 0 && glp.material < length(scene.materials)){ + let mat = scene.materials[glp.material] + if (mat.alphaMode == GltfAlphaMode.blend){continue} + bind_material(model, mat) + }else{bind_material(model, GltfMaterial())} + gltf_pbr_vs_bind_uniform(program); if (material_buffer){gltf_pbr_material_fs_bind_uniform(program)}else{gltf_pbr_normal_fs_bind_uniform(program)} + gltf_gl_draw_primitive(glp) + } + } + glUseProgram(0u) +} + +//! Draw opaque/masked geometry with a flat color and the ordinary skinning path. +//! Caller owns depth test/write state. Draw hidden before visible geometry to avoid self-overlays. +def gltf_pbr_render_silhouette(var r : GltfPbrRenderer; model : GltfGlModel; scene : GltfScene; + root, vp : float4x4; color : float4) { + if (r.silhouetteProgram == 0u){r.silhouetteProgram = create_shader_program(@@gltf_pbr_vs, @@gltf_pbr_silhouette_fs)} + glUseProgram(r.silhouetteProgram); u_viewProj = vp; u_silhouette_color = color + for (node in scene.nodes){ + if (node.mesh < 0 || node.mesh >= length(model.meshes)){continue} + bind_node(scene, node, root) + for (glp in model.meshes[node.mesh].primitives){ + if (glp.material >= 0 && glp.material < length(scene.materials)){ + let mat = scene.materials[glp.material] + if (mat.alphaMode == GltfAlphaMode.blend){continue} + bind_material(model, mat) + }else{bind_material(model, GltfMaterial())} + gltf_pbr_vs_bind_uniform(r.silhouetteProgram); gltf_pbr_silhouette_fs_bind_uniform(r.silhouetteProgram) + gltf_gl_draw_primitive(glp) + } + } + glUseProgram(0u) +} + //! Render every mesh instance in `scene` with the PBR shader, using the GL objects in `model`. A node //! with a `skin` is drawn skinned (its joint palette is uploaded and the mesh-node transform ignored); //! otherwise the node's precomputed `world` matrix is the model transform. Per primitive the material @@ -247,21 +521,33 @@ def gltf_pbr_render(r : GltfPbrRenderer; model : GltfGlModel; scene : GltfScene; u_ambient = light.ambient u_env_tex := (r.envTex != 0u ? r.envTex : model.whiteTex) u_env_amount = r.envTex != 0u ? light.environment : 0.0 + u_material_scale = r.materialScale; u_material_wetness = r.materialWetness; u_reflection_amount = r.envTex != 0u ? r.reflectionAmount : 0.0 u_env_max_lod = r.envMaxLod + u_shadow_tex := r.shadowTex != 0u ? r.shadowTex : model.whiteTex + u_shadow_vp = r.shadowViewProj + u_shadow_texel = r.shadowTexel + u_shadow_bias = r.shadowBias + u_shadow_strength = r.shadowStrength + u_ssao_tex := r.aoTex != 0u ? r.aoTex : model.whiteTex + u_ssao_enabled = r.aoTex != 0u ? 1.0 : 0.0 + u_ssao_inverse_size = r.aoInverseSize + u_fog_color = r.atmosphere.color + u_fog_params = float4(r.atmosphere.density, r.atmosphere.start, r.atmosphere.height_density, r.atmosphere.height_top) + u_fog_falloff = r.atmosphere.height_falloff + u_background_tint = r.backgroundTint + u_local_shadow_tex := r.localShadowTex != 0u ? r.localShadowTex : model.whiteTex + u_local_shadow_size = r.localShadowSize + u_point_count = r.pointCount + for (i in range(GLTF_MAX_POINT_LIGHTS)) { + u_point_shadow[i] = r.pointShadow[i] + u_point_position[i] = r.pointPosition[i] + u_point_color[i] = r.pointColor[i] + } for (node in scene.nodes) { if (node.mesh < 0 || node.mesh >= length(model.meshes)) { continue } - if (node.skin >= 0 && node.skin < length(scene.skins)) { - bind_skin_palette(scene, node.skin, root) - u_skinned = 1.0 - u_model = identity4x4() // skinned path blends the (root-baked) palette; u_model is unused - u_normal_matrix = identity3x3() // unused in the skinned branch - } else { - u_skinned = 0.0 - u_model = root * node.world - u_normal_matrix = float3x3(transpose(inverse(u_model))) - } + bind_node(scene, node, root) for (glp in model.meshes[node.mesh].primitives) { if (glp.material >= 0 && glp.material < length(scene.materials)) { bind_material(model, scene.materials[glp.material]) diff --git a/modules/dasGLTF/gltf/gltf_pbr_common.das b/modules/dasGLTF/gltf/gltf_pbr_common.das index 7f8a63f72e..b07dc82ebb 100644 --- a/modules/dasGLTF/gltf/gltf_pbr_common.das +++ b/modules/dasGLTF/gltf/gltf_pbr_common.das @@ -10,6 +10,25 @@ require math // globals — so both shader emitters (dasGlsl -> GLSL, dasSpirv -> SPIR-V) lower them as user // shader functions; callers sample textures and pass the values in. +def gltf_shadow_pcf_weights(fraction : float) : float4 { + //! Combined weights of three adjacent bilinear comparison taps. Four nearest + //! depth comparisons reproduce them without interpolating raw depth values. + //! The weights shift continuously as the receiver crosses a shadow texel. + return float4(1.0 - fraction, 1.0, 1.0, fraction) / 3.0 +} + +def gltf_shadow_receiver_gradient(dx, dy : float3) : float2 { + //! Depth slope in shadow UV coordinates, independent of the camera-screen basis. + let determinant = dx.x * dy.y - dx.y * dy.x + if (abs(determinant) < 0.000000000001) { return float2(0.0) } + return float2(dy.y * dx.z - dx.y * dy.z, dx.x * dy.z - dy.x * dx.z) / determinant +} + +def gltf_shadow_receiver_depth(receiver : float3; uv, gradient : float2; bias : float) : float { + //! Compare at each PCF texel's position on the receiver plane, not the center depth. + return receiver.z + dot(gradient, uv - receiver.xy) - bias +} + def gltf_ggx_distribution(n, h : float3; roughness : float) : float { //! Trowbridge-Reitz GGX normal distribution. let a = roughness * roughness diff --git a/modules/dasGLTF/gltf/gltf_processed.das b/modules/dasGLTF/gltf/gltf_processed.das new file mode 100644 index 0000000000..e83d39d5aa --- /dev/null +++ b/modules/dasGLTF/gltf/gltf_processed.das @@ -0,0 +1,324 @@ +options gen2 +options persistent_heap +module gltf_processed shared public +require gltf/gltf_boost public +require stbimage/texture_blocks public +require meshoptimizer +require math +require daslib/math_boost +require daslib/fio +require daslib/json_boost +require daslib/f16_cvt +require strings + +let DAS_GLTF_VERSION = 1 +let DAS_GLTF_PROCESSOR_VERSION = 2 +let VERTEX_UV = 1 +let VERTEX_TANGENT = 2 +let VERTEX_SKIN = 4 +let VERTEX_UV1 = 8 +let VERTEX_COLOR = 16 +struct ProcessingProfile { + //! Controls which optional vertex streams and storage encodings survive preprocessing. + keep_uv1, keep_color : bool //! Preserve secondary UVs and vertex colors when true. + compress_storage : bool = true //! Encode triangle geometry with meshoptimizer when true. + texture_cache : string = ".jitted_scripts/assets/textures" //! Directory for content-addressed block textures; empty disables cache I/O. +} +struct ProcessedPrimitive { + //! Portable packed geometry for one primitive, optionally meshoptimizer-encoded. + material : int = -1 //! Index into the processed scene material array, or -1. + mode : GltfPrimitiveMode = GltfPrimitiveMode.triangles //! Original glTF primitive topology. + attributes, stride, vertex_count, index_count : int //! Attribute flags, byte stride, and decoded element counts. + encoded : bool //! True when both payload arrays use meshoptimizer codecs. + vertices, indices : array //! Packed vertex bytes and uint32 index bytes, or their encoded forms. + lo, hi : float3 //! Object-space bounds of source positions, including unreferenced vertices. +} +struct ProcessedMesh { + //! A named mesh containing portable processed primitives. + name : string //! Source mesh name. + primitives : array //! Processed primitives in source order. +} +struct ProcessedAsset { + //! Backend-neutral processed glTF data: scene metadata, packed geometry, and BC textures. + version : int //! Serialized asset layout version. + key : uint64 //! Content and processing-profile key used by manifests and caches. + source : string //! Source identifier included in the key and diagnostics. + scene : GltfScene //! Nodes, materials, skins, animations, samplers, and texture references. + meshes : array //! Portable geometry addressed by scene nodes. + images : array //! Deduplicated compressed images addressed by scene textures. + lo, hi : float3 //! World-space bounds computed from the source scene. + source_vertices, processed_vertices, texture_hits, texture_misses : int //! Processing statistics for geometry and the texture cache. +} +struct ProcessedCatalogEntry { + //! Identifies one processed asset and the key that produced it. + path : string //! Asset path interpreted by the catalog consumer. + key : uint64 //! Expected processed asset key. +} +struct ProcessedCatalog { + //! Versioned list of processed assets for tooling and package manifests. + version : int //! Catalog schema version. + assets : array //! Catalog entries in author-defined order. +} +struct ProcessedManifest { + //! Split-file manifest for geometry and independently shared texture payloads. + version : int //! Manifest schema version. + key : uint64 //! Expected key of the geometry payload. + geometry : string //! Geometry filename relative to the manifest directory. + textures : array //! Texture filenames relative to the manifest directory. +} +def processed_stride(flags : int) : int { + //! Return the packed vertex stride for the `VERTEX_*` attribute bit mask. Position and normal + //! are always present; optional streams add their fixed portable widths. + return 16 + ((flags & VERTEX_UV) != 0 ? 4 : 0) + ((flags & VERTEX_TANGENT) != 0 ? 4 : 0) + ((flags & VERTEX_SKIN) != 0 ? 16 : 0) + ((flags & VERTEX_UV1) != 0 ? 4 : 0) + ((flags & VERTEX_COLOR) != 0 ? 4 : 0) +} +[arch(at="../ARCHITECTURE.md#processed-vertex-portability")] +def private pack_uv(v : float2) : uint {return f32_to_f16(v.x) | (f32_to_f16(v.y) << 16)} +def private unpack_uv(v : uint) : float2 {return float2(f16_to_f32(v & 65535u), f16_to_f32(v >> 16))} +def private pack_direction(v : float4) : uint { + let x = int(round(clamp(v.x, -1.0, 1.0) * 511.0)) + let y = int(round(clamp(v.y, -1.0, 1.0) * 511.0)) + let z = int(round(clamp(v.z, -1.0, 1.0) * 511.0)) + let w = v.w < 0.0 ? 3u : (v.w > 0.0 ? 1u : 0u) + return (uint(x) & 1023u) | ((uint(y) & 1023u) << 10) | ((uint(z) & 1023u) << 20) | (w << 30) +} +def private unpack_direction(bits : uint) : float4 { + let x = int(bits << 22) >> 22; let y = int(bits << 12) >> 22; let z = int(bits << 2) >> 22; let w = int(bits) >> 30 + return float4(float(x) / 511.0, float(y) / 511.0, float(z) / 511.0, float(w)) +} +def private pack_vertices(vertices : array; flags : int) : array { + let stride = processed_stride(flags); var words : array; words |> reserve(length(vertices) * stride / 4); words |> resize(length(vertices) * stride / 4) + for (v, i in vertices, count()){ + var at = i * stride / 4 + words[at++] = unsafe(reinterpret(v.position.x)); words[at++] = unsafe(reinterpret(v.position.y)); words[at++] = unsafe(reinterpret(v.position.z)) + words[at++] = pack_direction(float4(v.normal, 0.0)) + if ((flags & VERTEX_UV) != 0){words[at++] = pack_uv(v.uv0)} + if ((flags & VERTEX_TANGENT) != 0){words[at++] = pack_direction(v.tangent)} + if ((flags & VERTEX_SKIN) != 0){ + for (c in range(4)){if (v.joints0[c] < 0.0 || v.joints0[c] > 65535.0){panic("Joint index does not fit processed layout")}} + let joints = unsafe(reinterpret(ushort4(uint4(v.joints0)))) + let weights = unsafe(reinterpret(ushort4(uint4(round(clamp(v.weights0, float4(0.0), float4(1.0)) * 65535.0))))) + words[at++] = joints.x; words[at++] = joints.y; words[at++] = weights.x; words[at++] = weights.y + } + if ((flags & VERTEX_UV1) != 0){words[at++] = pack_uv(v.uv1)} + if ((flags & VERTEX_COLOR) != 0){words[at++] = unsafe(reinterpret(ubyte4(uint4(round(clamp(v.color0, float4(0.0), float4(1.0)) * 255.0)))))} + } + var bytes : array; bytes |> reserve(length(words) * 4); bytes |> resize(length(words) * 4) + if (!empty(bytes)){unsafe{memcpy(addr(bytes[0]), addr(words[0]), length(bytes))}} + delete words; return <- bytes +} +def processed_vertex_bytes(p : ProcessedPrimitive) : array { + //! Return decoded packed vertex bytes for `p`. Panics when an encoded payload fails validation. + var out : array + if (p.encoded){if (!meshopt_decode_vertices(out, p.vertices, uint(p.vertex_count), uint(p.stride))){panic("Invalid meshopt vertex payload")}} + else{out <- clone(p.vertices)} + return <- out +} +def processed_indices(p : ProcessedPrimitive) : array { + //! Return decoded uint32 indices for `p`, rejecting codec failures and indices outside the + //! declared vertex range with a panic. + var out : array + if (p.encoded){if (!meshopt_decode_indices(out, p.indices, uint(p.index_count))){panic("Invalid meshopt index payload")}} + else{out |> reserve(p.index_count); out |> resize(p.index_count); if (!empty(out)){unsafe{memcpy(addr(out[0]), addr(p.indices[0]), p.index_count * 4)}}} + for (index in out){if (index >= uint(p.vertex_count)){panic("Processed index outside vertex buffer")}} + return <- out +} +def process_primitive(p : GltfPrimitive; flags : int; encode : bool; optimize_order : bool = true) : ProcessedPrimitive { + //! Pack one glTF primitive according to `flags`, deduplicate triangle vertices, and optionally + //! optimize ordering and encode storage. Non-triangle or unindexed primitives remain raw. + var out = ProcessedPrimitive(material = p.material, mode = p.mode, attributes = flags, stride = processed_stride(flags), lo = float3(1.0e20), hi = float3(-1.0e20)) + for (v in p.vertices){out.lo = min(out.lo, v.position); out.hi = max(out.hi, v.position)} + if (empty(p.vertices)){out.lo = float3(0.0); out.hi = float3(0.0); return <- out} + var bytes <- pack_vertices(p.vertices, flags); var indices <- clone(p.indices) + if (p.mode == GltfPrimitiveMode.triangles && !empty(indices)){ + var remap : array; let count = meshopt_generate_remap(remap, indices, bytes, uint(out.stride)) + var compact : array; var remapped : array + meshopt_remap_vertices(compact, bytes, remap, uint(out.stride), count) + meshopt_remap_indices(remapped, indices, remap) + if (optimize_order){meshopt_optimize_cache(remapped, count)} + meshopt_optimize_fetch(remapped, compact, uint(out.stride)) + delete bytes; delete indices; bytes <- compact; indices <- remapped; delete remap + } + out.vertex_count = length(bytes) / out.stride; out.index_count = length(indices) + out.encoded = encode && p.mode == GltfPrimitiveMode.triangles && !empty(indices) + if (out.encoded){meshopt_encode_vertices(out.vertices, bytes, uint(out.stride)); meshopt_encode_indices(out.indices, indices, uint(out.vertex_count))} + else{ + out.vertices <- bytes; out.indices |> reserve(length(indices) * 4); out.indices |> resize(length(indices) * 4) + if (!empty(indices)){unsafe{memcpy(addr(out.indices[0]), addr(indices[0]), length(out.indices))}} + } + delete bytes; delete indices; return <- out +} +def private process_gltf_textures(raw : GltfScene; profile : ProcessingProfile; var out : ProcessedAsset) { + out.images |> reserve(length(out.scene.textures)) + for (tex, ti in out.scene.textures, count()){ + if (tex.image < 0 || tex.image >= length(raw.images)){tex.image = -1; continue} + let image & = unsafe(raw.images[tex.image]) + if (empty(image.pixels)){tex.image = -1; continue} + var srgb = false + for (m in raw.materials){if (m.baseColorTex == ti || m.emissiveTex == ti){srgb = true}} + var format = 0 + for (i in range(image.width * image.height)){if (image.pixels[i * 4 + 3] != 255u8){format = 1; break}} + let key = block_texture_key(image.pixels, image.width, image.height, format, srgb) + var found = -1 + for (img, i in out.images, count()){if (img.key == key){found = i; break}} + if (found < 0){ + var hit = false + var compressed <- cached_block_texture(image.pixels, image.width, image.height, format, srgb, profile.texture_cache, hit) + if (hit){out.texture_hits++}else{out.texture_misses++} + found = length(out.images); out.images |> emplace(compressed) + } + tex.image = found + } +} +def process_gltf(raw : GltfScene; source : string; profile : ProcessingProfile) : ProcessedAsset { + //! Convert a loaded scene into backend-neutral processed geometry and block textures. `source` + //! participates in the asset key; the returned scene retains hierarchy, materials, skins, and animation. + var out = ProcessedAsset(version = DAS_GLTF_VERSION, key = hash(raw) ^ hash((version = DAS_GLTF_VERSION, processor = DAS_GLTF_PROCESSOR_VERSION, uv1 = profile.keep_uv1, color = profile.keep_color, storage = profile.compress_storage)) ^ hash(source), source = clone(source)) + let bounds = gltf_scene_bounds(raw); out.lo = bounds._0; out.hi = bounds._1 + out.scene.materials <- clone(raw.materials); out.scene.nodes <- clone(raw.nodes); out.scene.roots <- clone(raw.roots) + out.scene.skins <- clone(raw.skins); out.scene.animations <- clone(raw.animations); out.scene.samplers <- clone(raw.samplers); out.scene.textures <- clone(raw.textures) + out.meshes |> reserve(length(raw.meshes)) + for (mesh, mi in raw.meshes, count()){ + var result = ProcessedMesh(name = clone(mesh.name)); var skinned = false + for (node in raw.nodes){if (node.mesh == mi && node.skin >= 0){skinned = true}} + result.primitives |> reserve(length(mesh.primitives)) + for (p in mesh.primitives){ + var flags = (skinned ? VERTEX_SKIN : 0) | (profile.keep_uv1 ? VERTEX_UV1 : 0) | (profile.keep_color ? VERTEX_COLOR : 0) + if (p.material >= 0 && p.material < length(raw.materials)){ + let m = raw.materials[p.material] + if (m.baseColorTex >= 0 || m.metalRoughTex >= 0 || m.normalTex >= 0 || m.occlusionTex >= 0 || m.emissiveTex >= 0){flags |= VERTEX_UV} + if (m.normalTex >= 0){flags |= VERTEX_TANGENT} + } + let ordered = p.material >= 0 && p.material < length(raw.materials) && raw.materials[p.material].alphaMode == GltfAlphaMode.blend + var processed <- process_primitive(p, flags, profile.compress_storage, !ordered) + out.source_vertices += length(p.vertices); out.processed_vertices += processed.vertex_count; result.primitives |> emplace(processed) + } + out.meshes |> emplace(result) + } + process_gltf_textures(raw, profile, out) + return <- out +} +def unpack_processed_geometry(asset : ProcessedAsset) : GltfScene { + //! Reconstruct ordinary `GltfPrimitive` vertex and index arrays from a processed asset while + //! cloning its scene metadata. Panics if an encoded geometry payload cannot be decoded. + var out <- clone(asset.scene) + out.meshes |> reserve(length(out.meshes) + length(asset.meshes)) + for (mesh in asset.meshes){ + var result = GltfMesh(name = clone(mesh.name)) + result.primitives |> reserve(length(mesh.primitives)) + for (p in mesh.primitives){ + var prim = GltfPrimitive(material = p.material, mode = p.mode) + var bytes <- processed_vertex_bytes(p); var words : array; words |> reserve(length(bytes) / 4); words |> resize(length(bytes) / 4) + if (!empty(bytes)){unsafe{memcpy(addr(words[0]), addr(bytes[0]), length(bytes))}} + prim.vertices |> reserve(p.vertex_count); prim.vertices |> resize(p.vertex_count) + for (v, i in prim.vertices, count()){ + var at = i * p.stride / 4 + v.position = float3(unsafe(reinterpret(words[at])), unsafe(reinterpret(words[at + 1])), unsafe(reinterpret(words[at + 2]))); at += 3 + v.normal = unpack_direction(words[at++]).xyz + if ((p.attributes & VERTEX_UV) != 0){v.uv0 = unpack_uv(words[at++])} + if ((p.attributes & VERTEX_TANGENT) != 0){v.tangent = unpack_direction(words[at++])} + if ((p.attributes & VERTEX_SKIN) != 0){ + v.joints0 = float4(uint4(unsafe(reinterpret(uint2(words[at], words[at + 1]))))); at += 2 + v.weights0 = float4(uint4(unsafe(reinterpret(uint2(words[at], words[at + 1]))))) / 65535.0; at += 2 + } + if ((p.attributes & VERTEX_UV1) != 0){v.uv1 = unpack_uv(words[at++])} + v.color0 = (p.attributes & VERTEX_COLOR) != 0 ? float4(uint4(unsafe(reinterpret(words[at++])))) / 255.0 : float4(1.0) + } + prim.indices <- processed_indices(p); result.primitives |> emplace(prim); delete bytes; delete words + } + out.meshes |> emplace(result) + } + return <- out +} +def processed_valid(a : ProcessedAsset) : bool { + //! Check serialized version, size limits, packed strides and raw byte counts, compressed textures, + //! and scene references. This is a cheap structural check; `load_processed` additionally decodes + //! encoded geometry and validates every index before returning untrusted serialized data. + if (a.version != DAS_GLTF_VERSION || length(a.meshes) > 65536){return false} + for (mesh in a.meshes){for (p in mesh.primitives){ + if (p.attributes < 0 || p.attributes > 31 || p.stride != processed_stride(p.attributes) || p.vertex_count < 0 || p.index_count < 0 || p.vertex_count > 10000000 || p.index_count > 30000000 || + (!p.encoded && (long_length(p.vertices) != int64(p.vertex_count) * int64(p.stride) || long_length(p.indices) != int64(p.index_count) * 4l))){return false} + }} + for (image in a.images){if (!block_texture_valid(image)){return false}} + for (texture in a.scene.textures){if (texture.image < -1 || texture.image >= length(a.images)){return false}} + for (node in a.scene.nodes){if (node.mesh < -1 || node.mesh >= length(a.meshes)){return false}} + return true +} +def private processed_payloads_valid(a : ProcessedAsset) : bool { + for (mesh in a.meshes){for (p in mesh.primitives){ + var vertices : array + if (p.encoded && !meshopt_decode_vertices(vertices, p.vertices, uint(p.vertex_count), uint(p.stride))){delete vertices; return false} + delete vertices + var indices : array + if (p.encoded){ + if (p.mode != GltfPrimitiveMode.triangles || p.index_count == 0 || p.index_count % 3 != 0 || + !meshopt_decode_indices(indices, p.indices, uint(p.index_count))){delete indices; return false} + }else{ + indices |> reserve(p.index_count); indices |> resize(p.index_count) + if (!empty(indices)){unsafe{memcpy(addr(indices[0]), addr(p.indices[0]), p.index_count * 4)}} + } + for (index in indices){if (index >= uint(p.vertex_count)){delete indices; return false}} + delete indices + }} + return true +} +def private save_binary_file(path : string; data : auto) : bool { + let parent = dir_name(path); mkdir_rec(parent) + let temporary = "{path}.tmp-{ref_time_ticks()}"; var ok = false + fopen(temporary, "wb") $(f){if (f != null){ok = fsave(f, data) > 0}} + if (ok){var error : string; ok = rename(temporary, path, error)} + if (!ok){remove(temporary)} + return ok +} +def save_processed(asset : ProcessedAsset; path : string; shared_directory : string = "") : bool { + //! Save a validated asset as one `.das_glb` binary or as a `.das_gltf` manifest plus geometry + //! and texture files. `shared_directory` allows textures to be shared across manifests. + if (!processed_valid(asset)){return false} + if (ends_with(path, ".das_glb")){return save_binary_file(path, asset)} + if (!ends_with(path, ".das_gltf")){return false} + let dir = dir_name(path) + var manifest = ProcessedManifest(version = DAS_GLTF_VERSION, key = asset.key, geometry = "{asset.key}.das_mesh") + var geometry <- clone(asset); delete geometry.images + var ok = save_binary_file("{dir}/{manifest.geometry}", geometry); delete geometry + manifest.textures |> reserve(length(asset.images)) + for (image in asset.images){ + let storage = empty(shared_directory) ? dir : shared_directory + let destination = "{storage}/textures/{image.key}.das_tex" + var error : string; let file = relative(destination, dir, error) + if (!empty(error) || empty(file)){ok = false; break} + manifest.textures |> push(file) + if (!save_binary_file(destination, image)){ok = false} + } + if (ok){ok = fwrite(path, sprint_json(manifest, false))} + delete manifest; return ok +} +def load_processed(path : string) : ProcessedAsset { + //! Load and validate a `.das_glb` binary or split `.das_gltf` manifest. Panics on missing, + //! malformed, mismatched, or unsupported data. + var out : ProcessedAsset; var ok = false + if (ends_with(path, ".das_glb")){fopen(path, "rb") $(f){if (f != null){ok = fload(f, out)}}} + elif (ends_with(path, ".das_gltf")){ + var m : ProcessedManifest + if (sscan_json(fread(path), m) && m.version == DAS_GLTF_VERSION){ + let dir = dir_name(path) + fopen("{dir}/{m.geometry}", "rb") $(f){if (f != null){ok = fload(f, out)}} + if (ok && out.key == m.key){out.images |> reserve(length(out.images) + length(m.textures)); for (file in m.textures){ + var image : BlockTexture; var loaded = false + fopen("{dir}/{file}", "rb") $(f){if (f != null){loaded = fload(f, image)}} + if (!loaded){ok = false}; out.images |> emplace(image) + }}else{ok = false} + } + delete m + } + if (!ok || !processed_valid(out) || !processed_payloads_valid(out)){delete out; panic("Invalid processed glTF: {path}")} + return <- out +} +def load_and_process_gltf(path : string; profile : ProcessingProfile = ProcessingProfile()) : ProcessedAsset { + //! Load an existing processed file by extension, otherwise parse and process a source glTF. + //! Panics when the source is missing, unreadable, or contains no nodes, meshes, or animations. + if (ends_with(path, ".das_gltf") || ends_with(path, ".das_glb")){return <- load_processed(path)} + if (!stat(path).is_valid){panic("Asset not found: {path}")} + var source <- load_gltf(path) + if (empty(source.nodes) && empty(source.meshes) && empty(source.animations)){delete source; panic("Empty or unreadable glTF: {path}")} + var result <- process_gltf(source, path, profile) + delete source; return <- result +} diff --git a/modules/dasGLTF/gltf/gltf_processed_gl.das b/modules/dasGLTF/gltf/gltf_processed_gl.das new file mode 100644 index 0000000000..62d81603ed --- /dev/null +++ b/modules/dasGLTF/gltf/gltf_processed_gl.das @@ -0,0 +1,145 @@ +options gen2 +options persistent_heap +module gltf_processed_gl shared public +require gltf/gltf_gl public +require gltf/gltf_processed public +require daslib/safe_addr +require math +require strings + +[arch(at="../ARCHITECTURE.md#compressed-texture-capability")] +def private compressed_format_supported(format : uint) : bool { + var count = 0; glGetIntegerv(GL_NUM_COMPRESSED_TEXTURE_FORMATS, safe_addr(count)) + var formats : array; formats |> resize(max(count, 1)) + if (count > 0){glGetIntegerv(GL_COMPRESSED_TEXTURE_FORMATS, unsafe(addr(formats[0])))} + var found = false; for (i in range(count)){if (uint(formats[i]) == format){found = true}} + delete formats + if (!found && (format == 0x8c4cu || format == 0x8c4fu || format == 0x83f0u || format == 0x83f3u)){ + var s3tc = false; var srgb = false; var extensions = 0 + glGetIntegerv(GL_NUM_EXTENSIONS, safe_addr(extensions)) + for (i in range(extensions)){ + let p = glGetStringi(GL_EXTENSIONS, uint(i)) + let name = p == null ? "" : clone_string(unsafe(reinterpret(p))) + if (ends_with(name, "texture_compression_s3tc") || ends_with(name, "compressed_texture_s3tc")){s3tc = true} + if (ends_with(name, "texture_sRGB") || ends_with(name, "compressed_texture_s3tc_srgb") || ends_with(name, "texture_compression_s3tc_srgb")){srgb = true} + } + let p = glGetString(GL_VERSION); let version = p == null ? "" : clone_string(unsafe(reinterpret(p))) + if (starts_with(version, "3.") || starts_with(version, "4.")){srgb = true} + found = s3tc && ((format == 0x83f0u || format == 0x83f3u) || srgb) + } + return found +} +def private block_gl_format(t : BlockTexture) : uint { + if (t.format == 0){return t.srgb ? 0x8c4cu : 0x83f0u} + if (t.format == 1){return t.srgb ? 0x8c4fu : 0x83f3u} + return t.format == 2 ? 0x8dbbu : 0x8dbdu +} +[arch(at="../ARCHITECTURE.md#compressed-texture-capability")] +def private upload_blocks(t : BlockTexture; sampler : GltfSampler; var bytes : uint64&) : uint { + let format = block_gl_format(t) + var compressed = compressed_format_supported(format) + for (m in t.mips){if ((m.width % 4 != 0 && m.width != 1 && m.width != 2) || (m.height % 4 != 0 && m.height != 1 && m.height != 2)){compressed = false}} + var texture = 0u; glGenTextures(1, safe_addr(texture)); glBindTexture(GL_TEXTURE_2D, texture); bytes = 0ul + for (m, level in t.mips, count()){ + if (compressed){ + glCompressedTexImage2D(GL_TEXTURE_2D, level, format, m.width, m.height, 0, length(m.data), unsafe(addr(m.data[0]))) + bytes += uint64(long_length(m.data)) + }else{ + var pixels <- decode_block_mip(t, level) + glTexImage2D(GL_TEXTURE_2D, level, int(t.srgb ? GL_SRGB8_ALPHA8 : GL_RGBA8), m.width, m.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, unsafe(addr(pixels[0]))) + bytes += uint64(long_length(pixels)); delete pixels + } + } + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, sampler.minFilter == 0 ? int(GL_LINEAR_MIPMAP_LINEAR) : sampler.minFilter) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, sampler.magFilter == 0 ? int(GL_LINEAR) : sampler.magFilter) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, sampler.wrapS == 0 ? int(GL_REPEAT) : sampler.wrapS) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, sampler.wrapT == 0 ? int(GL_REPEAT) : sampler.wrapT) + glBindTexture(GL_TEXTURE_2D, 0u); return texture +} +def private packed_attribute(index : uint; count : int; kind : uint; normalized : bool; stride, offset : int){ + glEnableVertexAttribArray(index); glVertexAttribPointer(index, count, kind, normalized, stride, null, offset) +} +//! Return the generic vertex attributes a processed primitive must restore at draw time. +def gltf_processed_missing_attributes(attributes : int) : uint { + var missing = 0u + if ((attributes & VERTEX_TANGENT) == 0){missing |= GLTF_GL_ATTR_TANGENT} + if ((attributes & VERTEX_UV) == 0){missing |= GLTF_GL_ATTR_UV0} + if ((attributes & VERTEX_UV1) == 0){missing |= GLTF_GL_ATTR_UV1} + if ((attributes & VERTEX_COLOR) == 0){missing |= GLTF_GL_ATTR_COLOR0} + if ((attributes & VERTEX_SKIN) == 0){missing |= GLTF_GL_ATTR_JOINTS0 | GLTF_GL_ATTR_WEIGHTS0} + return missing +} +//! Derive the packed vertex layout for a mesh instance and material in a scene. +def gltf_packed_primitive_attributes(scene : GltfScene; mesh_index, material_index : int) : int { + var attributes = VERTEX_UV + for (node in scene.nodes){if (node.mesh == mesh_index && node.skin >= 0){attributes |= VERTEX_SKIN}} + if (material_index >= 0 && material_index < length(scene.materials) && scene.materials[material_index].normalTex >= 0){attributes |= VERTEX_TANGENT} + return attributes +} +def upload_processed_primitive(p : ProcessedPrimitive) : GltfGlPrimitive { + var out = GltfGlPrimitive(material = p.material, indexCount = p.index_count, mode = p.mode == GltfPrimitiveMode.triangles ? GL_TRIANGLES : (p.mode == GltfPrimitiveMode.lines ? GL_LINES : GL_POINTS)) + out.missingAttributes = gltf_processed_missing_attributes(p.attributes) + var vertices <- processed_vertex_bytes(p); var indices <- processed_indices(p) + glGenVertexArrays(1, unsafe(addr(out.vao))); glBindVertexArray(out.vao) + glGenBuffers(1, unsafe(addr(out.vbo))); glBindBuffer(GL_ARRAY_BUFFER, out.vbo); glBufferData(GL_ARRAY_BUFFER, vertices, GL_STATIC_DRAW) + out.vertexBytes = uint64(long_length(vertices)) + packed_attribute(0u, 3, GL_FLOAT, false, p.stride, 0) + packed_attribute(1u, 4, GL_INT_2_10_10_10_REV, true, p.stride, 12) + var at = 16 + if ((p.attributes & VERTEX_UV) != 0){packed_attribute(3u, 2, GL_HALF_FLOAT, false, p.stride, at); at += 4} + if ((p.attributes & VERTEX_TANGENT) != 0){packed_attribute(2u, 4, GL_INT_2_10_10_10_REV, true, p.stride, at); at += 4} + if ((p.attributes & VERTEX_SKIN) != 0){packed_attribute(6u, 4, GL_UNSIGNED_SHORT, false, p.stride, at); packed_attribute(7u, 4, GL_UNSIGNED_SHORT, true, p.stride, at + 8); at += 16} + if ((p.attributes & VERTEX_UV1) != 0){packed_attribute(4u, 2, GL_HALF_FLOAT, false, p.stride, at); at += 4} + if ((p.attributes & VERTEX_COLOR) != 0){packed_attribute(5u, 4, GL_UNSIGNED_BYTE, true, p.stride, at)} + glGenBuffers(1, unsafe(addr(out.ebo))); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, out.ebo) + if (p.vertex_count <= 65536){ + var short_indices : array; short_indices |> reserve(length(indices)); short_indices |> resize(length(indices)) + for (index, i in indices, count()){short_indices[i] = uint16(index)} + glBufferData(GL_ELEMENT_ARRAY_BUFFER, short_indices, GL_STATIC_DRAW); out.indexType = GL_UNSIGNED_SHORT; out.indexBytes = uint64(long_length(indices)) * 2ul + delete short_indices + }else{glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices, GL_STATIC_DRAW); out.indexType = GL_UNSIGNED_INT; out.indexBytes = uint64(long_length(indices)) * 4ul} + glBindVertexArray(0u); delete vertices; delete indices; return out +} +def upload_processed(asset : ProcessedAsset; var pool : GltfGlTexturePool) : GltfGlModel { + if (!processed_valid(asset)){panic("Invalid processed asset upload")} + var model <- upload_gltf(asset.scene) + model.texturePool = unsafe(addr(pool)); model.textureKeys |> resize(length(asset.scene.textures)) + for (texture, ti in asset.scene.textures, count()){ + if (texture.image < 0){continue} + let image & = unsafe(asset.images[texture.image]) + var sampler = texture.sampler >= 0 && texture.sampler < length(asset.scene.samplers) ? asset.scene.samplers[texture.sampler] : GltfSampler() + if (sampler.wrapS == 0){sampler.wrapS = int(GL_REPEAT)}; if (sampler.wrapT == 0){sampler.wrapT = int(GL_REPEAT)} + if (sampler.minFilter == 0){sampler.minFilter = int(GL_LINEAR_MIPMAP_LINEAR)}; if (sampler.magFilter == 0){sampler.magFilter = int(GL_LINEAR)} + let key = (image.key ^ hash(sampler)) | 1ul + if (!key_exists(pool.entries, key)){ + var bytes = 0ul; let id = upload_blocks(image, sampler, bytes) + pool.entries[key] = GltfGlTextureEntry(texture = id, bytes = bytes) + } + pool.entries[key].references++; model.textures[ti] = pool.entries[key].texture; model.textureKeys[ti] = key + } + model.meshes |> reserve(length(model.meshes) + length(asset.meshes)) + for (mesh in asset.meshes){ + var glmesh : GltfGlMesh + glmesh.primitives |> reserve(length(mesh.primitives)) + for (p in mesh.primitives){glmesh.primitives |> emplace(upload_processed_primitive(p))} + model.meshes |> emplace(glmesh) + } + return <- model +} +def upload_gltf_geometry_packed(scene : GltfScene; texture_owner : GltfGlModel) : GltfGlModel { + var model = GltfGlModel(borrowsTextures = true, whiteTex = texture_owner.whiteTex, flatNormalTex = texture_owner.flatNormalTex) + model.textures <- clone(texture_owner.textures) + model.meshes |> reserve(length(model.meshes) + length(scene.meshes)) + for (mesh, mi in scene.meshes, count()){ + var glmesh : GltfGlMesh + glmesh.primitives |> reserve(length(mesh.primitives)) + for (p in mesh.primitives){ + let flags = gltf_packed_primitive_attributes(scene, mi, p.material) + let ordered = p.material >= 0 && p.material < length(scene.materials) && scene.materials[p.material].alphaMode == GltfAlphaMode.blend + var processed <- process_primitive(p, flags, false, !ordered) + glmesh.primitives |> emplace(upload_processed_primitive(processed)); delete processed + } + model.meshes |> emplace(glmesh) + } + return <- model +} diff --git a/modules/dasGLTF/gltf/gltf_scene.das b/modules/dasGLTF/gltf/gltf_scene.das index 3955ef3531..74de53fab4 100644 --- a/modules/dasGLTF/gltf/gltf_scene.das +++ b/modules/dasGLTF/gltf/gltf_scene.das @@ -118,12 +118,19 @@ def evaluate_animation(var scene : GltfScene; animation : int; t : float) { //! Nodes given by an explicit `matrix` are never animation targets (glTF forbids it), so only touched //! (hence TRS-defined) nodes are recomposed — matrix-defined nodes keep their parsed local. if (animation < 0 || animation >= length(scene.animations)) return - let nch = length(scene.animations[animation].channels) + evaluate_animation(scene, scene.animations[animation], t) +} + +def evaluate_animation(var scene : GltfScene; clip : GltfAnimation; t : float) { + //! Evaluate `clip` at `t` seconds into `scene`, allowing multiple scene instances to share + //! animation data without cloning it. Channel targets update node TRS values, animated local + //! matrices are recomposed, and world transforms are refreshed; invalid channels are skipped. + let nch = length(clip.channels) for (ci in range(nch)) { - let ch = scene.animations[animation].channels[ci] + let ch = clip.channels[ci] if (ch.targetNode < 0 || ch.targetNode >= length(scene.nodes) || - ch.sampler < 0 || ch.sampler >= length(scene.animations[animation].samplers)) continue - let val = eval_sampler(scene.animations[animation].samplers[ch.sampler], t, + ch.sampler < 0 || ch.sampler >= length(clip.samplers)) continue + let val = eval_sampler(clip.samplers[ch.sampler], t, ch.targetPath == GltfPath.rotation) if (ch.targetPath == GltfPath.translation) { scene.nodes[ch.targetNode].translation = val.xyz @@ -137,7 +144,7 @@ def evaluate_animation(var scene : GltfScene; animation : int; t : float) { // Second pass: recompose the local of each animated node from its finalized TRS (recompose is // idempotent, so a node driven by multiple channels is fine). for (ci in range(nch)) { - let tn = scene.animations[animation].channels[ci].targetNode + let tn = clip.channels[ci].targetNode if (tn >= 0 && tn < length(scene.nodes)) { scene.nodes[tn].local = compose(scene.nodes[tn].translation, scene.nodes[tn].rotation, scene.nodes[tn].scale) diff --git a/modules/dasGLTF/tests/test_rendering_contracts.das b/modules/dasGLTF/tests/test_rendering_contracts.das new file mode 100644 index 0000000000..1c32d2615f --- /dev/null +++ b/modules/dasGLTF/tests/test_rendering_contracts.das @@ -0,0 +1,26 @@ +options gen2 +require dastest/testing_boost public +require gltf/gltf_processed_gl + +[test] +def test_packed_geometry_preserves_skinning_for_every_skinned_mesh_instance(t : T?){ + var scene : GltfScene + scene.materials |> push(GltfMaterial(normalTex = 0)) + scene.nodes <- [GltfNode(mesh = 1, skin = -1), GltfNode(mesh = 0, skin = 0)] + let skinned = gltf_packed_primitive_attributes(scene, 0, 0) + let rigid = gltf_packed_primitive_attributes(scene, 1, -1) + t |> success((skinned & VERTEX_SKIN) != 0) + t |> success((skinned & VERTEX_TANGENT) != 0) + t |> success((rigid & VERTEX_SKIN) == 0) +} + +[test] +def test_processed_draw_mask_tracks_only_missing_generic_attributes(t : T?){ + let minimal = gltf_processed_missing_attributes(VERTEX_UV) + t |> success((minimal & GLTF_GL_ATTR_UV0) == 0u) + t |> success((minimal & GLTF_GL_ATTR_TANGENT) != 0u) + t |> success((minimal & GLTF_GL_ATTR_JOINTS0) != 0u) + t |> success((minimal & GLTF_GL_ATTR_WEIGHTS0) != 0u) + let complete = gltf_processed_missing_attributes(VERTEX_UV | VERTEX_TANGENT | VERTEX_SKIN | VERTEX_UV1 | VERTEX_COLOR) + t |> equal(complete, 0u) +} diff --git a/modules/dasGLTF/tests/test_surface_controls.das b/modules/dasGLTF/tests/test_surface_controls.das new file mode 100644 index 0000000000..f3162888e9 --- /dev/null +++ b/modules/dasGLTF/tests/test_surface_controls.das @@ -0,0 +1,21 @@ +options gen2 +require dastest/testing_boost public +require gltf/gltf_pbr +require math + +[test] +def test_surface_controls_preserve_standard_defaults(t : T?){ + let renderer = GltfPbrRenderer() + t |> equal(renderer.materialScale, float2(1.0)); t |> equal(renderer.materialWetness, 0.0); t |> equal(renderer.reflectionAmount, 0.0) + u_material_scale = float2(1.0); u_material_wetness = 0.0; u_roughness_factor = .5 + t |> equal(gltf_surface_roughness(float4(1.0)), .5) +} +[test] +def test_optional_wetness_respects_red_mask_and_roughness_limits(t : T?){ + u_material_scale = float2(1.0); u_roughness_factor = .8; u_material_wetness = 1.0 + t |> success(abs(gltf_surface_roughness(float4(1.0)) - .16) < .0001) + t |> equal(gltf_surface_roughness(float4(0.0, 1.0, 0.0, 1.0)), .8) + u_material_wetness = 0.0; u_material_scale = float2(.01, 1.0) + t |> equal(gltf_surface_roughness(float4(1.0)), .04) + u_material_scale = float2(1.0); u_roughness_factor = 1.0 +} diff --git a/modules/dasGLTF/tools/convert.das b/modules/dasGLTF/tools/convert.das new file mode 100644 index 0000000000..48ec35ff00 --- /dev/null +++ b/modules/dasGLTF/tools/convert.das @@ -0,0 +1,71 @@ +options gen2 +options gc +options persistent_heap +require gltf/gltf_processed +require daslib/fio +require daslib/clargs +require strings + +def private convert_inputs(inputs : array; root, output : string; binary, force : bool; profile : ProcessingProfile; var catalog : ProcessedCatalog) : int2 { + var converted = 0; var skipped = 0 + for (path in inputs){ + var error : string; let rel = empty(root) ? base_name(path) : relative(path, root, error) + if (!empty(error) || starts_with(rel, "../")){panic("Input outside --root: {path}")} + let extension = binary ? ".das_glb" : ".das_gltf" + let destination = "{output}/{slice(rel,0,length(rel)-(ends_with(rel,".gltf")?5:4))}{extension}" + var asset <- load_and_process_gltf(path, profile) + var unchanged = false + if (!force){ + fopen(destination, "rb") $(f){if (f != null){ + try{var previous <- load_processed(destination); unchanged = previous.key == asset.key; delete previous}recover{unchanged = false} + }} + } + if (unchanged){skipped++} + else{ + if (!save_processed(asset, destination, output)){panic("Could not write {destination}")} + converted++ + } + print("{converted+skipped}/{length(inputs)} {unchanged?"cached":"converted"}: {rel}; vertices {asset.source_vertices}->{asset.processed_vertices}; textures {length(asset.images)} ({asset.texture_hits} cached)\n") + let asset_path = relative(destination, output, error) + var found = false + for (entry in catalog.assets){if (entry.path == asset_path){entry.key = asset.key; found = true; break}} + if (!found){catalog.assets |> push(ProcessedCatalogEntry(path = asset_path, key = asset.key))} + delete asset + } + return int2(converted, skipped) +} +[export] +def main(){ + var args <- get_user_args(); var inputs : array + var root = ""; var output = ""; var cache = ".jitted_scripts/assets/textures" + var binary = false; var force = false; var i = 0 + while (i < length(args)){ + let arg = args[i++] + if (arg == "--root" || arg == "--out" || arg == "--cache"){ + if (i >= length(args)){panic("Missing value after {arg}")} + let value = args[i++] + if (arg == "--root"){root = clone(value)}elif (arg == "--out"){output = clone(value)}else{cache = clone(value)} + }elif (arg == "--self-contained"){binary = true} + elif (arg == "--force"){force = true} + elif (arg == "--help"){ + print("convert.das -- --root SOURCE --out DEST [--cache DIR] [--force] [--self-contained] [FILES...]\n") + return + }else{inputs |> push(clone(arg))} + } + if (empty(output)){panic("Specify --out; original assets are never overwritten")} + if (empty(inputs)){ + if (empty(root)){panic("Specify --root or input files")} + dir_rec(root) $(file, is_dir){if (!is_dir && (ends_with(file, ".gltf") || ends_with(file, ".glb"))){inputs |> push("{root}/{file}")}} + } + if (empty(inputs)){panic("No glTF or GLB files found") } + sort(inputs); mkdir_rec(output) + var profile = ProcessingProfile(texture_cache = cache) + var catalog : ProcessedCatalog + sscan_json(fread("{output}/asset-build.json"), catalog); catalog.version = DAS_GLTF_VERSION + let totals = convert_inputs(inputs, root, output, binary, force, profile, catalog) + sort(catalog.assets) $(a, b) => a.path < b.path + if (!fwrite("{output}/asset-build.json", sprint_json(catalog, false))){panic("Could not write asset build manifest")} + delete catalog + print("Done: {totals.x} converted, {totals.y} unchanged. Output: {output}\n") + delete args; delete inputs; delete profile +} diff --git a/modules/dasImgui/ARCHITECTURE.md b/modules/dasImgui/ARCHITECTURE.md new file mode 100644 index 0000000000..b7820dd9a9 --- /dev/null +++ b/modules/dasImgui/ARCHITECTURE.md @@ -0,0 +1,13 @@ +# dasImgui architecture + +## 1. Browser callback routing {#browser-callback-routing} + +On Emscripten, ImGui ignores the embedded GLFW scroll callback. Initialization +installs ImGui's browser callbacks against the persistent `#canvas` selector. +The wheel listener bypasses GLFW, so `das_imgui_set_real_input_callbacks` installs +and removes it alongside the GLFW callbacks to keep the real-input gate coherent. + +The binding removes its resize and fullscreen listeners before deleting the backend. +Those listeners retain backend data; upstream shutdown only removes the wheel listener. +Ownership follows the context that installed the embedded-GLFW browser callbacks. +The contrib GLFW port retains its own callback lifecycle. diff --git a/modules/dasImgui/REVIEW.md b/modules/dasImgui/REVIEW.md index 543ee7259f..b1a3ad2472 100644 --- a/modules/dasImgui/REVIEW.md +++ b/modules/dasImgui/REVIEW.md @@ -1,7 +1,7 @@ # dasImgui Code Review Checklist **Read `REVIEW_COMMON.md` (repo root) first - its contract binds this checklist.** Architecture doc: -`CLAUDE.md`. +`ARCHITECTURE.md`. **Never add or change a dasImgui test file - a `.das` file that declares a `[test]` function or is named `test_*.das`, `failed_imgui_*.das`, or `record_*.das` - outside `modules/dasImgui/tests` diff --git a/modules/dasImgui/src/module_imgui_app.cpp b/modules/dasImgui/src/module_imgui_app.cpp index f337148793..024bf693fd 100644 --- a/modules/dasImgui/src/module_imgui_app.cpp +++ b/modules/dasImgui/src/module_imgui_app.cpp @@ -5,6 +5,9 @@ #include #include "../imgui/backends/imgui_impl_glfw.h" +#ifdef __EMSCRIPTEN__ +#include +#endif using namespace das; @@ -31,6 +34,41 @@ DAS_MOD_API void glfw_error_callback(int error, const char* description) { printf("Glfw Error %d: %s\n", error, description); } +#if defined(__EMSCRIPTEN__) && !defined(EMSCRIPTEN_USE_PORT_CONTRIB_GLFW3) +static ImGuiContext * g_emscripten_callback_owner = nullptr; +#endif + +#ifdef __EMSCRIPTEN__ +static void install_imgui_browser_callbacks(GLFWwindow * window) { + ImGui_ImplGlfw_InstallEmscriptenCallbacks(window, "#canvas"); +#ifndef EMSCRIPTEN_USE_PORT_CONTRIB_GLFW3 + g_emscripten_callback_owner = ImGui::GetCurrentContext(); +#endif +} +#endif + +// modules/dasImgui/ARCHITECTURE.md sec.1 +DAS_MOD_API bool das_imgui_init_glfw_for_opengl ( GLFWwindow * window, bool install_callbacks ) { + const bool ok = ImGui_ImplGlfw_InitForOpenGL(window, install_callbacks); +#ifdef __EMSCRIPTEN__ + if ( ok && install_callbacks ) + install_imgui_browser_callbacks(window); +#endif + return ok; +} + +// modules/dasImgui/ARCHITECTURE.md sec.1 +DAS_MOD_API void das_imgui_shutdown_glfw() { +#if defined(__EMSCRIPTEN__) && !defined(EMSCRIPTEN_USE_PORT_CONTRIB_GLFW3) + if (g_emscripten_callback_owner == ImGui::GetCurrentContext()) { + emscripten_set_resize_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, nullptr, false, nullptr); + emscripten_set_fullscreenchange_callback(EMSCRIPTEN_EVENT_TARGET_DOCUMENT, nullptr, false, nullptr); + g_emscripten_callback_owner = nullptr; + } +#endif + ImGui_ImplGlfw_Shutdown(); +} + // ===================================================================== // Synthetic IO bypass — direct injection into ImGui's input queue, // skipping the GLFW backend chain. Used by imgui_live's synth driver to @@ -68,11 +106,16 @@ DAS_MOD_API void das_imgui_synth_input_char ( uint32_t cp ) { // Caller toggles only on a state change, honoring ImGui_ImplGlfw's // InstalledCallbacks invariant (Install asserts when already installed, and // vice-versa). Must run on the render/main thread — glfwSet*Callback requires it. +// modules/dasImgui/ARCHITECTURE.md sec.1 DAS_MOD_API void das_imgui_set_real_input_callbacks ( bool enabled ) { GLFWwindow * w = glfwGetCurrentContext(); if ( !w ) return; if ( enabled ) ImGui_ImplGlfw_InstallCallbacks(w); else ImGui_ImplGlfw_RestoreCallbacks(w); +#ifdef __EMSCRIPTEN__ + if ( enabled ) install_imgui_browser_callbacks(w); + else emscripten_set_wheel_callback("#canvas", nullptr, false, nullptr); +#endif } // Set a defined Arrow cursor on the window at init, before the first frame. The GLFW @@ -115,14 +158,14 @@ class Module_imgui_app : public Module { lib.addModule(mod_imgui); #if USE_GENERATED // GLFW - addExtern(*this,lib,"ImGui_ImplGlfw_InitForOpenGL", - SideEffects::worstDefault, "ImGui_ImplGlfw_InitForOpenGL"); + addExtern(*this,lib,"ImGui_ImplGlfw_InitForOpenGL", + SideEffects::worstDefault, "das_imgui_init_glfw_for_opengl"); addExtern(*this,lib,"ImGui_ImplGlfw_InitForVulkan", SideEffects::worstDefault, "ImGui_ImplGlfw_InitForVulkan"); addExtern(*this,lib,"ImGui_ImplGlfw_InitForOther", SideEffects::worstDefault, "ImGui_ImplGlfw_InitForOther"); - addExtern(*this,lib,"ImGui_ImplGlfw_Shutdown", - SideEffects::worstDefault, "ImGui_ImplGlfw_Shutdown"); + addExtern(*this,lib,"ImGui_ImplGlfw_Shutdown", + SideEffects::worstDefault, "das_imgui_shutdown_glfw"); addExtern(*this,lib,"ImGui_ImplGlfw_NewFrame", SideEffects::worstDefault, "ImGui_ImplGlfw_NewFrame"); // Detach/reattach the backend's GLFW input callbacks at runtime — used by @@ -158,6 +201,8 @@ class Module_imgui_app : public Module { virtual ModuleAotType aotRequire ( TextWriter & tw ) const override { tw << "#include \"../modules/dasImgui/src/imgui_stub.h\"\n"; tw << "#include \n"; + tw << "DAS_MOD_API void das_imgui_shutdown_glfw();\n"; + tw << "DAS_MOD_API bool das_imgui_init_glfw_for_opengl ( GLFWwindow * window, bool install_callbacks );\n"; tw << "DAS_MOD_API void das_imgui_synth_mouse_pos ( float x, float y );\n"; tw << "DAS_MOD_API void das_imgui_synth_mouse_button ( int button, bool down );\n"; tw << "DAS_MOD_API void das_imgui_synth_mouse_wheel ( float dx, float dy );\n"; @@ -173,4 +218,3 @@ REGISTER_DYN_MODULE(Module_imgui_app, Module_imgui_app); // registering module, so that its available via 'NEED_MODULE' macro REGISTER_MODULE(Module_imgui_app); - diff --git a/modules/dasImgui/tests/test_snapshot_payload_ownership.das b/modules/dasImgui/tests/test_snapshot_payload_ownership.das new file mode 100644 index 0000000000..0e31e547b2 --- /dev/null +++ b/modules/dasImgui/tests/test_snapshot_payload_ownership.das @@ -0,0 +1,16 @@ +options gen2 +require dastest/testing_boost public +require imgui/imgui_boost_runtime + +def fixture_payload(_ctx : void?; _id : int) : JsonValue? {return JV((text = "snapshot payload"))} +[test] +def test_snapshot_transfers_payload_ownership(t : T?) { + let context = CreateContext(null) + g_registry["capture_fixture"] <- WidgetEntry(kind = "fixture", serialize = @@fixture_payload) + var snapshot = imgui_snapshot(null) + t |> success(g_registry["capture_fixture"].payload == null, "registry must not retain a pointer owned by returned JSON") + t |> equal(snapshot?.globals?.capture_fixture?.payload?.text ?? "", "snapshot payload") + delete_json(snapshot) + clear(g_registry) + DestroyContext(context) +} diff --git a/modules/dasImgui/widgets/imgui_boost_runtime.das b/modules/dasImgui/widgets/imgui_boost_runtime.das index 8b9587e2cf..b3507f1766 100644 --- a/modules/dasImgui/widgets/imgui_boost_runtime.das +++ b/modules/dasImgui/widgets/imgui_boost_runtime.das @@ -615,7 +615,10 @@ def private widget_entry_jv(ident : string; var entry : WidgetEntry) : JsonValue } elif (entry.serialize != null) { entry.payload = entry.serialize(entry.serialize_ctx, entry.serialize_id) } - return JV(entry) + //! Transfers payload ownership to the returned JSON and clears the source alias. + var result = JV(entry) + entry.payload = null + return result } var g_registry : table @@ -993,15 +996,13 @@ def public register_widget(module_name : string; state_addr : void?; ti : TypeInfo const?) { //! Module-init registration of a non-indexed widget into the long-lived ``g_widgets`` table. ``state_addr`` is the stable address of the module-scope state global (passed by the ``[widget]`` macro as ``unsafe(addr(IDENT))``); ``ti`` is its ``TypeInfo`` (``typeinfo rtti_typeinfo(IDENT)``). Both feed ``sprint_json_at`` at snapshot time — no per-widget getter/serializer functions emitted. For indexed widgets use ``register_widget_indexed`` / ``register_widget_str``. - unsafe { - g_widgets[bare_ident] <- WidgetMeta( - module_name = module_name, - kind = kind, - state_addr = state_addr, - ti = ti, - last_seen_frame = -1 - ) - } + unsafe(g_widgets[bare_ident]) <- WidgetMeta( + module_name = module_name, + kind = kind, + state_addr = state_addr, + ti = ti, + last_seen_frame = -1 + ) } def public register_widget_indexed(module_name : string; @@ -1011,16 +1012,14 @@ def public register_widget_indexed(module_name : string; ti : TypeInfo const?; k : int) { //! Int-indexed sibling of ``register_widget``. ``addr_getter(k)`` re-resolves per-call against the user's ``table`` (slots may be erased between frames); ``ti`` is the element's ``TypeInfo``. ``k`` is the table key, stored once at registration. - unsafe { - g_widgets[bare_ident] <- WidgetMeta( - module_name = module_name, - kind = kind, - ti = ti, - addr_getter_int = addr_getter, - k_int = k, - last_seen_frame = -1 - ) - } + unsafe(g_widgets[bare_ident]) <- WidgetMeta( + module_name = module_name, + kind = kind, + ti = ti, + addr_getter_int = addr_getter, + k_int = k, + last_seen_frame = -1 + ) } def public register_widget_str(module_name : string; @@ -1030,17 +1029,15 @@ def public register_widget_str(module_name : string; ti : TypeInfo const?; k : string) { //! String-keyed sibling of ``register_widget_indexed`` for ``table``. Sets ``has_str_key`` so the lookup routes through ``addr_getter_str(k_str)``. - unsafe { - g_widgets[bare_ident] <- WidgetMeta( - module_name = module_name, - kind = kind, - ti = ti, - addr_getter_str = addr_getter, - k_str := k, - has_str_key = true, - last_seen_frame = -1 - ) - } + unsafe(g_widgets[bare_ident]) <- WidgetMeta( + module_name = module_name, + kind = kind, + ti = ti, + addr_getter_str = addr_getter, + k_str := k, + has_str_key = true, + last_seen_frame = -1 + ) } def public lookup_state_addr(path : string) : void? { @@ -1246,28 +1243,22 @@ def private widgets_mark_seen(bare_ident : string; path_key : string; if (bare_ident != path_key && key_exists(g_widgets, bare_ident)) { var moved <- g_widgets[bare_ident] g_widgets |> erase(bare_ident) - unsafe { - g_widgets[path_key] <- moved - } + unsafe(g_widgets[path_key]) <- moved } // Refresh (state_addr, ti) each frame; [edit_widget] widgets that skipped register_widget get their meta created lazily here, with the LAZY sentinel module_name so the freshness gate can expire them. if (state_addr != null && ti != null) { if (key_exists(g_widgets, path_key)) { - unsafe { - var meta & = g_widgets[path_key] - meta.state_addr = state_addr - meta.ti = ti - } + var meta & = unsafe(g_widgets[path_key]) + meta.state_addr = state_addr + meta.ti = ti } else { - unsafe { - g_widgets[path_key] <- WidgetMeta( - module_name = LAZY_EDIT_WIDGET_MODULE, - kind = kind, - state_addr = state_addr, - ti = ti, - last_seen_frame = -1 - ) - } + unsafe(g_widgets[path_key]) <- WidgetMeta( + module_name = LAZY_EDIT_WIDGET_MODULE, + kind = kind, + state_addr = state_addr, + ti = ti, + last_seen_frame = -1 + ) } } if (key_exists(g_widgets, path_key)) { @@ -1946,9 +1937,7 @@ def private dispatch_or_err(action : string; input : JsonValue?) : Result) } diff --git a/modules/dasImgui/widgets/imgui_drawlist_builtin.das b/modules/dasImgui/widgets/imgui_drawlist_builtin.das index 896273b4aa..ae6523f6b5 100644 --- a/modules/dasImgui/widgets/imgui_drawlist_builtin.das +++ b/modules/dasImgui/widgets/imgui_drawlist_builtin.das @@ -384,3 +384,18 @@ def public with_drawlist_clip_rect(var dl : ImDrawList?; clip_rect_min : float2; invoke(blk) *dl |> PopClipRect() } + +[drawlist_prim] +def public add_image_id(var dl : ImDrawList?; texture_id : uint64; a, b : float2; + uv_min : float2 = float2(0.0); uv_max : float2 = float2(1.0); color : uint = 0xffffffff) { + //! Draw a backend texture by handle and expose its bounds to live inspection. + *dl |> AddImage(ImTextureRef(texture_id), a, b, uv_min, uv_max, color) + drawlist_register(widget_ident, "add_image", float4(a, b)) +} +[drawlist_prim] +def public add_text_wrapped(var dl : ImDrawList?; pos : float2; color : uint; text : string; wrap_width : float) { + //! Draw wrapped prose with the current font and publish its visible bounds. + *dl |> AddText(GetFont(), GetFontSize(), pos, color, text, wrap_width, null) + let size = CalcTextSize(text, false, wrap_width) + drawlist_register(widget_ident, "add_text_wrapped", float4(pos, pos + size)) +} diff --git a/modules/dasImgui/widgets/imgui_live.das b/modules/dasImgui/widgets/imgui_live.das index a690ed6994..4bac216efe 100644 --- a/modules/dasImgui/widgets/imgui_live.das +++ b/modules/dasImgui/widgets/imgui_live.das @@ -118,6 +118,7 @@ def public live_imgui_render() { imgui_gl_render(live_imgui_gl) } +[arch(at="../ARCHITECTURE.md#browser-callback-routing")] def public live_imgui_shutdown() { //! Reload-aware ImGui shutdown — skips during reload so the ctx is reused, runs only on process exit. Call from your script's ``shutdown()``. if (live_imgui_ctx != null && !is_reload()) { diff --git a/modules/dasLiveHost/.das_module b/modules/dasLiveHost/.das_module index c428d36729..9f61a10762 100644 --- a/modules/dasLiveHost/.das_module +++ b/modules/dasLiveHost/.das_module @@ -6,7 +6,7 @@ def initialize(project_path : string) { if (das_is_dll_build()) { register_dynamic_module("{project_path}/dasModuleLiveHost.shared_module", "Module_LiveHost") } - let live_paths = ["live_gc", "live_commands", "live_api", "live_api_builtins", "live_api_stdio", "live_watch", "live_watch_boost", "decs_live", "live_vars"] + let live_paths = ["live_gc", "live_capture", "live_commands", "live_api", "live_api_builtins", "live_api_stdio", "live_watch", "live_watch_boost", "decs_live", "live_vars"] for (path in live_paths) { register_native_path("live", "{path}", "{project_path}/live/{path}.das") } diff --git a/modules/dasLiveHost/live/README_capture.md b/modules/dasLiveHost/live/README_capture.md new file mode 100644 index 0000000000..dd06ada901 --- /dev/null +++ b/modules/dasLiveHost/live/README_capture.md @@ -0,0 +1,29 @@ +# Registered live captures + +`require live/live_capture` adds reusable capture and restore alongside live commands. +Register `LiveCaptureProvider(name,version,read,validate,apply)` for logical state. +Read returns newly owned JSON. Validate must be side-effect-free and reject malformed +state. Apply runs only after all providers validate; it must not fail or retain borrowed +JSON pointers. Inspection-only providers may omit validate/apply. Providers rebuild +transient resources; they must not serialize GPU handles or process pointers for replay. + +Use `live_capture_diagnostic(name)` to opt read-only live commands into a capture. +The registry intentionally does not invoke every registered command: many mutate state. +`live_capture_event(category,payload)` retains the newest 256 events, bounded to 4096 +bytes each. Applications may attach their own input, navigation or event history. + +Native live commands: + +- `live_capture`: current provider state and registered diagnostics. +- `live_capture_restore`: validate and restore a snapshot or report bundle. +- `live_capture_inspect_file`: inspect saved JSON; optional `command` selects an existing + captured diagnostic without executing it. +- `live_capture_restore_file`: restore a saved snapshot/report through the same providers. + +For a frame-aligned screenshot, configure a mailbox and artifact callback, request capture, +and flush after rendering. Browser integration is described in `../web/README.md`. + +A scene provider should validate dimensions, array/index bounds, asset references and +rendering ranges before applying a snapshot. It can freeze simulation and lock the captured +viewport for reproduction. Its source fingerprint must match the browser build. Historical +reports remain inspectable even when replay is refused. diff --git a/modules/dasLiveHost/live/live_capture.das b/modules/dasLiveHost/live/live_capture.das new file mode 100644 index 0000000000..c4cad83133 --- /dev/null +++ b/modules/dasLiveHost/live/live_capture.das @@ -0,0 +1,131 @@ +options gen2 +options persistent_heap +module live_capture shared public +require live/live_commands public +require daslib/fio public +require math +require strings +require daslib/json_boost public + +// Opt-in only: capturing never enumerates/invokes arbitrary mutating live commands. +typedef LiveCaptureRead = function<(args : JsonValue?) : JsonValue?> +typedef LiveCaptureValidate = function<(state : JsonValue?; var error : string&) : bool> +typedef LiveCaptureApply = function<(state : JsonValue?) : void> +struct LiveCaptureProvider { + name : string + version : int = 1 + read : LiveCaptureRead + validate : LiveCaptureValidate + apply : LiveCaptureApply +} +struct LiveCaptureEvent { sequence : int; category, payload : string } +var capture_providers : array +var capture_diagnostics : array +var capture_history : array +var capture_application, capture_build : string +var capture_sequence = 0 +var capture_event_sequence = 0 +var capture_frame = 0 +var capture_pending = false +var capture_mailbox = "" +var capture_request_id = "" +var capture_message = "" +var capture_last_file = "" +var capture_artifact : function<(file : string) : bool> + +def live_capture_configure(application, build, mailbox : string) { + capture_application = application; capture_build = build; capture_mailbox = mailbox + mkdir_rec(mailbox) +} +def live_capture_provider(provider : LiveCaptureProvider) { + for (p in capture_providers){if (p.name == provider.name){panic("Duplicate capture provider: {p.name}")}} + capture_providers |> push(provider) +} +def live_capture_diagnostic(command : string){capture_diagnostics |> push(clone(command))} +def live_capture_event(category, payload : string) { + if (length(capture_history) >= 256){erase(capture_history, 0)} + capture_history |> push(LiveCaptureEvent(sequence = capture_event_sequence++, category = category, payload = slice(payload, 0, min(length(payload), 4096)))) +} +def private capture_clone(value : JsonValue?) : JsonValue? { + var error : string; return read_json(write_json(value), error) +} +[live_command(description="Capture all registered live providers and opt-in read-only diagnostics. Does not run unregistered commands.")] +def live_capture(args : JsonValue?) : JsonValue? { + var providers <- {for (p in capture_providers); p.name => JV((version = p.version, state = invoke(p.read, null)))} + var diagnostics <- {for (name in capture_diagnostics); name => invoke(live_command_registry[name], null); where key_exists(live_command_registry, name)} + return JV((schema = "daslang.live.capture/1", application = capture_application, build = capture_build, + platform = get_running_platform_name(), sequence = capture_sequence++, frame = capture_frame, + description = args?.description ?? "", providers = JV(providers), diagnostics = JV(diagnostics), history = JV(capture_history))) +} +def private capture_document(args : JsonValue?) : JsonValue? { + return capture_clone(key_exists(args, "snapshot") ? args?.snapshot : args) +} +def live_capture_validate(document : JsonValue?; var error : string&) : bool { + if ((document?.schema ?? "") != "daslang.live.capture/1"){error = "Unsupported capture schema"; return false} + if ((document?.application ?? "") != capture_application){error = "Capture belongs to a different application"; return false} + if ((document?.build ?? "") != capture_build){error = "Build mismatch: use the capture's matching build"; return false} + for (p in capture_providers){ + if (p.apply == null){continue} + let entry = document?.providers?[p.name] + if (entry == null || (entry?.version ?? 0) != p.version){error = "Missing/incompatible provider: {p.name}"; return false} + if (p.validate == null || !invoke(p.validate, entry?.state, error)){return false} + } + return true +} +[live_command(description="Restore registered logical state from a matching-build capture. Validates all providers before applying any.")] +def live_capture_restore(args : JsonValue?) : JsonValue? { + var document = capture_document(args) + var error : string + if (!live_capture_validate(document, error)){delete_json(document); return JV((ok = false, error = error))} + for (p in capture_providers){if (p.apply != null){invoke(p.apply, document?.providers?[p.name]?.state)}} + delete_json(document) + live_capture_event("restore", "Capture restored") + return JV((ok = true, build = capture_build)) +} +[live_command(description="Inspect a saved live capture file, optionally selecting a captured diagnostic command. No commands are executed.")] +def live_capture_inspect_file(args : JsonValue?) : JsonValue? { + let file = args?.file ?? "" + if (empty(file) || !fexist(file)){return JV((ok = false, error = "Capture file not found"))} + var error : string; var report = read_json(fread(file), error) + if (report == null){return JV((ok = false, error = error))} + var doc = capture_document(report) + let command = args?.command ?? "" + var result = capture_clone(empty(command) ? report : doc?.diagnostics?[command]) + delete_json(doc); delete_json(report); return result +} +[live_command(description="Restore a saved capture/report file using registered providers; requires matching application and build.")] +def live_capture_restore_file(args : JsonValue?) : JsonValue? { + let file = args?.file ?? "" + if (empty(file) || !fexist(file)){return JV((ok = false, error = "Capture file not found"))} + var error : string; var report = read_json(fread(file), error) + if (report == null){return JV((ok = false, error = error))} + var result = live_capture_restore(report); delete_json(report); return result +} +def live_capture_request(){capture_pending = true; capture_request_id = "button-{capture_sequence}"} +def live_capture_poll() { + let file = "{capture_mailbox}/request.json" + if (empty(capture_mailbox) || !fexist(file)){return } + var error : string; var request = read_json(fread(file), error); remove(file) + capture_request_id = clone(request?.id ?? "") + let op = request?.operation ?? "" + if (op == "capture"){capture_pending = true} + else{ + var result = op == "restore" ? live_capture_restore(request?.report) : JV((ok = false, error = "Unknown capture operation")) + var reply = JV((id = capture_request_id, operation = op, result = result)) + fwrite("{capture_mailbox}/response.json", write_json(reply)); delete_json(reply) + } + delete_json(request) +} +def live_capture_flush(frame : int) { + capture_frame = frame + if (!capture_pending){return } + capture_pending = false + let png = "{capture_mailbox}/scene.png" + let image_ok = capture_artifact != null && invoke(capture_artifact, png) + var document = live_capture(null) + capture_last_file = "{capture_mailbox}/capture-{capture_sequence}.json" + fwrite(capture_last_file, write_json(document)) + var reply = JV((id = capture_request_id, operation = "capture", snapshot = document, image = image_ok ? png : "", file = capture_last_file)) + fwrite("{capture_mailbox}/response.json", write_json(reply)); delete_json(reply) + capture_message = "Captured: {capture_last_file}" +} diff --git a/modules/dasLiveHost/tests/test_capture_transport.cjs b/modules/dasLiveHost/tests/test_capture_transport.cjs new file mode 100644 index 0000000000..d78fecd033 --- /dev/null +++ b/modules/dasLiveHost/tests/test_capture_transport.cjs @@ -0,0 +1,29 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); +const test = require('node:test'); +const files = new Map(), timers = new Map(); +let timerId = 0; +const element = () => ({style:{},append(){},remove(){},setAttribute(){},addEventListener(){},focus(){},replaceChildren(){},getContext(){throw Error('No GPU in transport test');},width:640,height:480}); +const canvas = element(); +const context = {console:{warn(){},error(){}},window:{addEventListener(){}},document:{createElement:element,body:element(),getElementById(){return canvas},querySelector(){return canvas}},Module:{calledRun:true,FS:{mkdirTree(){},writeFile(path,value){files.set(path,value)},readFile(path){if(!files.has(path))throw Error('missing');return files.get(path)},analyzePath(path){return{exists:files.has(path)}},unlink(path){files.delete(path)}}},setTimeout(fn,delay){const id=++timerId;timers.set(id,{fn,delay});return id},clearTimeout(id){timers.delete(id)},navigator:{userAgent:'test',platform:'test'},location:{pathname:'/'},devicePixelRatio:1,performance:{now(){return 0}},Date,JSON}; +vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../web/live_capture.js'),'utf8'),context); +const api=context.window.dasLiveCapture; +function tick(delay){const pair=[...timers].find(([,x])=>x.delay===delay);assert.ok(pair);timers.delete(pair[0]);pair[1].fn();} +test('capture transport correlates replies and accepts native requests', async()=>{ + await assert.rejects(api.restore({}),/not a live report/); + const first=api.capture();const timedOut=assert.rejects(first,/20 seconds/); + await assert.rejects(api.capture(),/already running/); + const oldId=JSON.parse(files.get('/live-capture/request.json')).id; + tick(20000);await timedOut; + const second=api.capture();const newId=JSON.parse(files.get('/live-capture/request.json')).id; + files.set('/live-capture/response.json',JSON.stringify({id:oldId,operation:'capture',snapshot:{frame:1,build:'fixture'}})); + tick(80); + assert.equal(api.current,null,'a timed-out reply must not replace the current report'); + files.set('/live-capture/response.json',JSON.stringify({id:newId,operation:'capture',snapshot:{frame:2,build:'fixture'}})); + tick(80);assert.equal((await second).snapshot.frame,2);assert.equal(files.has('/live-capture/response.json'),false); + files.set('/live-capture/response.json',JSON.stringify({id:'button-3',operation:'capture',snapshot:{frame:3,build:'fixture'}})); + tick(80);assert.equal(api.current.snapshot.frame,3,'native UI capture remains available'); + context.Module.calledRun=false;await assert.rejects(api.capture(),/still loading/); +}); diff --git a/modules/dasLiveHost/tests/test_live_capture.das b/modules/dasLiveHost/tests/test_live_capture.das new file mode 100644 index 0000000000..8096a0220a --- /dev/null +++ b/modules/dasLiveHost/tests/test_live_capture.das @@ -0,0 +1,47 @@ +options gen2 +require dastest/testing_boost public +require live/live_capture + +var value = 7 +var unwanted_calls = 0 +def read_fixture(_args : JsonValue?) : JsonValue? {return JV((number = value))} +def validate_fixture(args : JsonValue?; var error : string&) : bool { + if ((args?.number ?? -1) < 0 || (args?.number ?? -1) > 100){error = "fixture out of range"; return false} + return true +} +def apply_fixture(args : JsonValue?){value = args?.number ?? 0} +[live_command] +def unrelated_mutation(_args : JsonValue?) : JsonValue? {unwanted_calls++; return JV(true)} +def setup_capture_fixture() { + clear(capture_providers); clear(capture_diagnostics); clear(capture_history) + capture_application = "fixture"; capture_build = "build-A"; value = 7; unwanted_calls = 0 + live_capture_provider(LiveCaptureProvider(name = "state", version = 1, read = @@read_fixture, validate = @@validate_fixture, apply = @@apply_fixture)) +} +[test] +def test_capture_is_opt_in_and_restores(t : T?) { + setup_capture_fixture() + for (_i in range(300)){live_capture_event("input", "event")} + var snapshot = live_capture(null) + t |> equal(length(capture_history), 256) + t |> equal(unwanted_calls, 0, "capture must not call unrelated live commands") + value = 90 + var result = live_capture_restore(snapshot) + t |> success(result?.ok ?? false, "matching snapshot restored") + t |> equal(value, 7) + delete_json(snapshot); delete_json(result) +} +[test] +def test_capture_validation_precedes_mutation(t : T?) { + setup_capture_fixture(); value = 101 + var invalid = live_capture(null); value = 42 + var result = live_capture_restore(invalid) + t |> success(!(result?.ok ?? true), "invalid provider rejected") + t |> equal(value, 42, "rejected restore leaves state intact") + delete_json(result); delete_json(invalid) + var snapshot = live_capture(null) + capture_build = "build-B"; value = 77 + result = live_capture_restore(snapshot) + t |> success(!(result?.ok ?? true), "wrong build rejected") + t |> equal(value, 77) + delete_json(snapshot); delete_json(result) +} diff --git a/modules/dasLiveHost/web/README.md b/modules/dasLiveHost/web/README.md new file mode 100644 index 0000000000..d60e05f7ee --- /dev/null +++ b/modules/dasLiveHost/web/README.md @@ -0,0 +1,34 @@ +# Browser live capture transport + +`live_capture.js` connects an application's exported Emscripten `Module.FS` to the +transport-neutral daslang `live/live_capture` module. Snapshot/validation/restore +logic stays in daslang. The JavaScript layer supplies report UI, browser/GPU metadata, +console diagnostics, upload/download and a file picker. F8 requests a capture; the +application can also call `live_capture_request()` from its own UI. + +Configure `/live-capture` as the WASM mailbox. Call `live_capture_poll()` before +simulation and `live_capture_flush(frame)` after rendering. The latter pairs provider +state with an application-supplied framebuffer PNG from that same frame. Do not call +it after a later simulation update. Requests and responses use monotonically unique +client IDs; only capture and validated restore operations cross this browser mailbox. + +Wire formats: + +- Snapshot: `daslang.live.capture/1`, with application/build/frame, versioned providers, + opt-in diagnostics and a bounded event history. +- Report bundle: `daslang.live.report/1`, containing `snapshot`, optional scene PNG data + URL, description, browser/GPU metadata, console and input history. + +The optional backend implements POST/GET `/api/reports`, GET `/api/report?id=R…` +and GET `/api/report-image?id=R…`. Upload is an explicit user action. A failed upload +leaves the local bundle available through Download. Restore requires the matching +application/build and validates every restorable provider before applying any. + +Load `live_capture.js` in the page body **before** the Emscripten launcher script. +It installs the report-dialog keyboard barrier before GLFW registers its window-level +capture listeners. Blocking only at the dialog's bubble phase is too late: GLFW +already prevents Backspace and Tab defaults. Key-up events still reach GLFW to clear +any modifiers held when entering the dialog. + +The transport regression test runs without a browser: `node --test modules/dasLiveHost/tests/test_capture_transport.cjs`. +It covers request correlation, timeouts, native capture notifications and loading guards. diff --git a/modules/dasLiveHost/web/live_capture.js b/modules/dasLiveHost/web/live_capture.js new file mode 100644 index 0000000000..31567fc724 --- /dev/null +++ b/modules/dasLiveHost/web/live_capture.js @@ -0,0 +1,95 @@ +/* Transport/UI adapter only. Providers, validation and replay run through daslang live commands. */ +(function () { + for (const type of ['keydown','keypress']) window.addEventListener(type,e=>{ + if(e.target.closest?.('#live-report-dialog'))e.stopImmediatePropagation(); + },true); + const root='/live-capture', pending=new Map(), logs=[]; + let serial=0,current=null,panel=null,message=null,list=null; + const retain=(kind,args)=>{logs.push({time:performance.now(),kind,text:args.map(x=>String(x)).join(' ').slice(0,4096)});if(logs.length>100)logs.shift();}; + for(const kind of ['warn','error']){const original=console[kind];console[kind]=function(...args){retain(kind,args);return original.apply(console,args);};} + window.addEventListener('error',e=>retain('error',[e.message,e.filename,e.lineno])); + window.addEventListener('unhandledrejection',e=>retain('rejection',[e.reason])); + function filesystem(){return typeof Module!=='undefined'&&Module.calledRun?Module.FS:null;} + function send(operation,report){ + const fs=filesystem();if(!fs)return Promise.reject(Error('The preview is still loading.')); + if(pending.size)return Promise.reject(Error('A capture or restore is already running.')); + const id='web-'+(++serial); + fs.mkdirTree(root);fs.writeFile(root+'/request.json',JSON.stringify({id,operation,report})); + return new Promise((resolve,reject)=>{const timer=setTimeout(()=>{pending.delete(id);reject(Error('The preview did not answer within 20 seconds.'));},20000);pending.set(id,{resolve,reject,timer});}); + } + function bytesURL(bytes){let binary='';for(let i=0;iURL.revokeObjectURL(url),1000); + } + function close(){if(panel)panel.remove();panel=null;document.querySelector('canvas').focus();} + function button(label,fn){const b=document.createElement('button');b.textContent=label;b.type='button';b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){if(message)message.textContent=String(e.message||e)+' You can still download the bundle.';}finally{b.disabled=false;}};return b;} + function open(report){ + if(panel)panel.remove();current=report||null; + panel=document.createElement('section');panel.id='live-report-dialog';panel.setAttribute('role','dialog');panel.setAttribute('aria-label','Live bug report');panel.addEventListener('keydown',e=>e.stopPropagation());panel.addEventListener('keyup',e=>e.stopPropagation()); + Object.assign(panel.style,{position:'fixed',inset:'5vh 10vw',zIndex:10000,overflow:'auto',background:'#171c24',color:'#eee',padding:'24px',border:'1px solid #75808f',borderRadius:'10px',font:'14px system-ui',boxShadow:'0 15px 70px #000b'}); + const title=document.createElement('h2');title.textContent=report?'Captured live report':'Saved live reports';panel.append(title); + message=document.createElement('p');message.id='live-report-status';message.setAttribute('role','status');panel.append(message); + if(report){ + const info=document.createElement('p');info.textContent='Frame '+report.snapshot.frame+' · Build '+report.snapshot.build;panel.append(info); + if(report.image){const image=document.createElement('img');image.src=report.image.data_url;image.alt='Captured scene';Object.assign(image.style,{display:'block',maxWidth:'100%',maxHeight:'38vh',objectFit:'contain'});panel.append(image);} + const note=document.createElement('textarea');note.id='live-report-description';note.maxLength=4096;note.placeholder='What went wrong?';note.setAttribute('aria-label','Bug description');note.value=report.description||'';Object.assign(note.style,{display:'block',width:'95%',minHeight:'65px',margin:'12px 0'});panel.append(note); + panel.append(button('Upload report',async()=>{report.description=note.value;const result=await upload(report);report.server_id=result.id;message.textContent='Saved '+result.id+' — give this ID to your assistant.';})); + panel.append(button('Download bundle',()=>{report.description=note.value;download(report);})); + panel.append(button('Restore this capture',()=>restore(report))); + if(report.server_id)message.textContent='Report '+report.server_id; + } + panel.append(button('Capture current scene',()=>capture())); + panel.append(button('Browse reports',()=>loadList())); + const file=document.createElement('input');file.type='file';file.accept='.json,application/json';file.setAttribute('aria-label','Load report file');file.onchange=async()=>{try{const report=JSON.parse(await file.files[0].text());open(report);}catch(e){message.textContent='Could not read report: '+e.message;}};panel.append(file); + panel.append(button('Close',()=>close())); + list=document.createElement('div');list.id='live-report-list';panel.append(list); + document.body.append(panel); + } + async function loadList(){ + if(!panel)open(null); + const response=await fetch('/api/reports');if(!response.ok)throw Error('Report backend is unavailable.'); + const reports=await response.json();list.replaceChildren(); + for(const meta of reports.reverse())list.append(button(meta.id+' — '+(meta.description||'No description'),async()=>{const r=await fetch('/api/report?id='+encodeURIComponent(meta.id));if(!r.ok)throw Error('Report not found');const report=await r.json();report.server_id=meta.id;open(report);})); + if(!reports.length)list.textContent='No reports yet.'; + } + function poll(){ + const fs=filesystem();if(fs){try{ + const path=root+'/response.json';if(fs.analyzePath(path).exists){ + const reply=JSON.parse(fs.readFile(path,{encoding:'utf8'}));fs.unlink(path); + const wait=pending.get(reply.id); + const nativeCapture=reply.operation==='capture'&&typeof reply.id==='string'&&reply.id.startsWith('button-'); + if(wait||nativeCapture){ + if(reply.operation==='capture'){current=makeReport(reply);open(current);} + if(wait){clearTimeout(wait.timer);pending.delete(reply.id);wait.resolve(reply);} + } + } + }catch(e){retain('transport',[e]);}} + setTimeout(poll,80); + } + const launcher=button('Reports',()=>loadList());launcher.id='live-report-launcher';Object.assign(launcher.style,{position:'fixed',right:'12px',bottom:'12px',zIndex:9000,padding:'8px 14px'});document.body.append(launcher); + window.addEventListener('keydown',e=>{if(e.key==='F8'){e.preventDefault();capture().catch(err=>{open(null);message.textContent=err.message;});}}); + window.dasLiveCapture={capture,restore,upload,download,list:loadList,get current(){return current;}}; + poll(); +})(); diff --git a/modules/dasMeshOptimizer/.das_module b/modules/dasMeshOptimizer/.das_module new file mode 100644 index 0000000000..e29a2f3bfb --- /dev/null +++ b/modules/dasMeshOptimizer/.das_module @@ -0,0 +1,8 @@ +options gen2 +require daslib/fio +[export] +def initialize(project_path : string){ + if (das_is_dll_build()){ + register_dynamic_module("{project_path}/dasModuleMeshOptimizer.shared_module", "Module_MeshOptimizer") + } +} diff --git a/modules/dasMeshOptimizer/CMakeLists.txt b/modules/dasMeshOptimizer/CMakeLists.txt new file mode 100644 index 0000000000..0eafd1cf72 --- /dev/null +++ b/modules/dasMeshOptimizer/CMakeLists.txt @@ -0,0 +1,55 @@ +if(NOT DAS_MESHOPTIMIZER_INCLUDED AND NOT DAS_MESHOPTIMIZER_DISABLED) + set(DAS_MESHOPTIMIZER_INCLUDED TRUE) + set(DAS_MESHOPTIMIZER_DIR ${PROJECT_SOURCE_DIR}/modules/dasMeshOptimizer) + set(MESHOPTIMIZER_SOURCE_DIR "${CMAKE_CURRENT_BINARY_DIR}/meshoptimizer/src/meshoptimizer") + set(MESHOPTIMIZER_SOURCES "${MESHOPTIMIZER_SOURCE_DIR}/src/meshoptimizer.h") + foreach(source IN ITEMS + allocator + clusterizer + indexanalyzer + indexcodec + indexgenerator + meshletcodec + meshletutils + opacitymap + overdrawoptimizer + partition + quantization + rasterizer + simplifier + spatialorder + stripifier + tangentspace + vcacheoptimizer + vertexcodec + vertexfilter + vfetchoptimizer +) + list(APPEND MESHOPTIMIZER_SOURCES "${MESHOPTIMIZER_SOURCE_DIR}/src/${source}.cpp") + endforeach() + + include(ExternalProject) + ExternalProject_Add(meshoptimizer_external + URL https://github.com/zeux/meshoptimizer/archive/9d9890c73011d75920af614485296d1e03e95448.tar.gz + URL_HASH SHA256=f002af938e3ba99a6ae50c0036dbbb17f3e433ce8b071d8597590d2d5649bda2 + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + PREFIX "${CMAKE_CURRENT_BINARY_DIR}/meshoptimizer" + SOURCE_DIR "${MESHOPTIMIZER_SOURCE_DIR}" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + BUILD_BYPRODUCTS ${MESHOPTIMIZER_SOURCES} + ) + set_source_files_properties(${MESHOPTIMIZER_SOURCES} PROPERTIES GENERATED TRUE) + + ADD_MODULE_CPP(MeshOptimizer) + ADD_MODULE_PUB_LIB(libDasModuleMeshOptimizer dasModuleMeshOptimizer + ${DAS_MESHOPTIMIZER_DIR}/src/dasMeshOptimizer.cpp + ${DAS_MESHOPTIMIZER_DIR}/src/dasMeshOptimizer.h ${MESHOPTIMIZER_SOURCES}) + foreach(target IN ITEMS libDasModuleMeshOptimizer dasModuleMeshOptimizer) + target_include_directories(${target} SYSTEM PRIVATE "${MESHOPTIMIZER_SOURCE_DIR}/src") + add_dependencies(${target} meshoptimizer_external) + SETUP_CPP11(${target}) + endforeach() + install(FILES ${DAS_MESHOPTIMIZER_DIR}/MESHOPTIMIZER.LICENSE DESTINATION ${DAS_INSTALL_DOCDIR}) +endif() diff --git a/modules/dasMeshOptimizer/MESHOPTIMIZER.LICENSE b/modules/dasMeshOptimizer/MESHOPTIMIZER.LICENSE new file mode 100644 index 0000000000..468827f4b8 --- /dev/null +++ b/modules/dasMeshOptimizer/MESHOPTIMIZER.LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016-2026 Arseny Kapoulkine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/modules/dasMeshOptimizer/README.md b/modules/dasMeshOptimizer/README.md new file mode 100644 index 0000000000..a5c030b3d7 --- /dev/null +++ b/modules/dasMeshOptimizer/README.md @@ -0,0 +1,24 @@ +# dasMeshOptimizer + +Shared, backend-neutral bindings to meshoptimizer **1.2** (MIT). CMake downloads +upstream commit `9d9890c73011d75920af614485296d1e03e95448` through `ExternalProject_Add` +and verifies its SHA-256. Sources stay in the build directory and compile directly +into the module archives with the parent toolchain, including WASM. The upstream +license is retained as `MESHOPTIMIZER.LICENSE`. + +`require meshoptimizer` exposes checked array adapters for vertex remapping, +vertex-cache optimization, vertex-fetch optimization, and vertex/index codecs. +The initial API operates on triangle lists, uint32 indices and packed byte streams +with a 4-byte-aligned stride up to 256 bytes. It rejects invalid buffer dimensions +and indices before calling the library. Decode functions return false and clear +the output on malformed encoded data. + +The processing order and vertex layout belong to the caller. No glTF, rendering, +LOD, or application policy lives in this module. See `modules/dasGLTF/gltf/gltf_processed.das` +for a consumer. Translucent materials preserve their triangle order there. + +The same bindings build as a native shared module and a WASM archive. The public +wrapper header supports daslang AOT/JIT. `daspkg build --wasm` includes this archive. + +Tests: `tests/test_meshoptimizer.das` covers deduplication, optimization, exact +packed-vertex codec round trips, oriented triangles and corrupt-input rejection. diff --git a/modules/dasMeshOptimizer/src/dasMeshOptimizer.cpp b/modules/dasMeshOptimizer/src/dasMeshOptimizer.cpp new file mode 100644 index 0000000000..cc89151066 --- /dev/null +++ b/modules/dasMeshOptimizer/src/dasMeshOptimizer.cpp @@ -0,0 +1,100 @@ +#include "daScript/misc/platform.h" +#include "daScript/ast/ast.h" +#include "daScript/ast/ast_interop.h" +#include "dasMeshOptimizer.h" +#include "meshoptimizer.h" +#include +namespace das { +template static T* ptr(TArray& a){return reinterpret_cast(a.data);} +template static const T* ptr(const TArray& a){return reinterpret_cast(a.data);} +static uint32_t check_vertices(const TArray& v,uint32_t stride,Context* c,LineInfoArg* at){ + if(!stride || stride>256 || stride%4 || v.size%stride || v.size>INT_MAX) c->throw_error_at(at,"meshoptimizer: invalid vertex stride"); + return v.size/stride; +} +static void check_indices(const TArray& i,uint32_t n,Context* c,LineInfoArg* at){ + if(i.size%3 || i.size>INT_MAX/4) c->throw_error_at(at,"meshoptimizer: expected triangle indices"); + for(uint32_t k=0;k=n)c->throw_error_at(at,"meshoptimizer: index out of bounds"); +} +static int checked_size(uint64_t size,Context* c,LineInfoArg* at){ + if(size>INT_MAX)c->throw_error_at(at,"meshoptimizer: buffer too large"); + return int(size); +} +static void resize_output(Array& out,int size,int stride,Context* c,LineInfoArg* at){ + builtin_array_reserve(out,size,stride,c,at);builtin_array_resize(out,size,stride,c,at); +} +uint32_t mo_remap(TArray& out,const TArray& i,const TArray& v,uint32_t stride,Context* c,LineInfoArg* at){ + const auto n=check_vertices(v,stride,c,at);check_indices(i,n,c,at); + if(ptr(out) && ptr(out)==ptr(i))c->throw_error_at(at,"meshoptimizer: remap output aliases indices"); + resize_output(out,n,4,c,at); + return uint32_t(meshopt_generateVertexRemap(ptr(out),ptr(i),i.size,ptr(v),n,stride)); +} +void mo_remap_vertices(TArray& out,const TArray& v,const TArray& r,uint32_t stride,uint32_t count,Context* c,LineInfoArg* at){ + const auto n=check_vertices(v,stride,c,at); + if(r.size!=n || (ptr(out) && ptr(out)==ptr(v)))c->throw_error_at(at,"meshoptimizer: invalid remap buffers"); + for(uint32_t k=0;k=count)c->throw_error_at(at,"meshoptimizer: invalid remap index"); + resize_output(out,checked_size(uint64_t(count)*stride,c,at),1,c,at); + meshopt_remapVertexBuffer(ptr(out),ptr(v),n,stride,ptr(r)); +} +void mo_remap_indices(TArray& out,const TArray& i,const TArray& r,Context* c,LineInfoArg* at){ + if(ptr(out) && ptr(out)==ptr(r))c->throw_error_at(at,"meshoptimizer: output aliases remap table"); + check_indices(i,r.size,c,at);resize_output(out,i.size,4,c,at);meshopt_remapIndexBuffer(ptr(out),ptr(i),i.size,ptr(r)); +} +void mo_cache(TArray& i,uint32_t count,Context* c,LineInfoArg* at){ + check_indices(i,count,c,at);meshopt_optimizeVertexCache(ptr(i),ptr(i),i.size,count); +} +uint32_t mo_fetch(TArray& i,TArray& v,uint32_t stride,Context* c,LineInfoArg* at){ + const auto n=check_vertices(v,stride,c,at);check_indices(i,n,c,at); + std::vector out(v.size); + const auto used=meshopt_optimizeVertexFetch(out.data(),ptr(i),i.size,ptr(v),n,stride); + builtin_array_resize(v,checked_size(used*stride,c,at),1,c,at); + if(v.size)memcpy(ptr(v),out.data(),v.size); + return uint32_t(used); +} +void mo_encode_vertices(TArray& out,const TArray& v,uint32_t stride,Context* c,LineInfoArg* at){ + const auto n=check_vertices(v,stride,c,at); + if(ptr(out) && ptr(out)==ptr(v))c->throw_error_at(at,"meshoptimizer: encoder output aliases input"); + resize_output(out,checked_size(meshopt_encodeVertexBufferBound(n,stride),c,at),1,c,at); + const auto size=meshopt_encodeVertexBuffer(ptr(out),out.size,ptr(v),n,stride); + builtin_array_resize(out,uint32_t(size),1,c,at); +} +bool mo_decode_vertices(TArray& out,const TArray& v,uint32_t n,uint32_t stride,Context* c,LineInfoArg* at){ + if(!stride || stride>256 || stride%4 || (ptr(out) && ptr(out)==ptr(v)))c->throw_error_at(at,"meshoptimizer: invalid decode buffers"); + resize_output(out,checked_size(uint64_t(n)*stride,c,at),1,c,at); + const bool ok=meshopt_decodeVertexBuffer(ptr(out),n,stride,ptr(v),v.size)==0; + if(!ok)builtin_array_resize(out,0,1,c,at); + return ok; +} +void mo_encode_indices(TArray& out,const TArray& i,uint32_t n,Context* c,LineInfoArg* at){ + check_indices(i,n,c,at);resize_output(out,checked_size(meshopt_encodeIndexBufferBound(i.size,n),c,at),1,c,at); + const auto size=meshopt_encodeIndexBuffer(ptr(out),out.size,ptr(i),i.size);builtin_array_resize(out,uint32_t(size),1,c,at); +} +bool mo_decode_indices(TArray& out,const TArray& v,uint32_t n,Context* c,LineInfoArg* at){ + if(n%3)c->throw_error_at(at,"meshoptimizer: expected triangle indices"); + checked_size(uint64_t(n)*4,c,at);resize_output(out,n,4,c,at); + const bool ok=meshopt_decodeIndexBuffer(ptr(out),n,4,ptr(v),v.size)==0; + if(!ok)builtin_array_resize(out,0,4,c,at); + return ok; +} +class Module_MeshOptimizer : public Module { +public: + Module_MeshOptimizer():Module("meshoptimizer"){ + ModuleLibrary lib;lib.addModule(this);lib.addBuiltInModule(); +#define BIND(fn,name) addExtern(*this,lib,name,SideEffects::modifyArgument,"das::" #fn) + BIND(mo_remap,"meshopt_generate_remap")->args({"remap","indices","vertices","stride","context","at"}); + BIND(mo_remap_vertices,"meshopt_remap_vertices")->args({"output","vertices","remap","stride","count","context","at"}); + BIND(mo_remap_indices,"meshopt_remap_indices")->args({"output","indices","remap","context","at"}); + BIND(mo_cache,"meshopt_optimize_cache")->args({"indices","count","context","at"}); + BIND(mo_fetch,"meshopt_optimize_fetch")->args({"indices","vertices","stride","context","at"}); + BIND(mo_encode_vertices,"meshopt_encode_vertices")->args({"output","vertices","stride","context","at"}); + BIND(mo_decode_vertices,"meshopt_decode_vertices")->args({"output","encoded","count","stride","context","at"}); + BIND(mo_encode_indices,"meshopt_encode_indices")->args({"output","indices","count","context","at"}); + BIND(mo_decode_indices,"meshopt_decode_indices")->args({"output","encoded","count","context","at"}); +#undef BIND + } + ModuleAotType aotRequire(TextWriter& tw) const override { + tw << "#include \"../modules/dasMeshOptimizer/src/dasMeshOptimizer.h\"\n";return ModuleAotType::cpp; + } +}; +REGISTER_DYN_MODULE(Module_MeshOptimizer,Module_MeshOptimizer); +} +REGISTER_MODULE_IN_NAMESPACE(Module_MeshOptimizer,das); diff --git a/modules/dasMeshOptimizer/src/dasMeshOptimizer.h b/modules/dasMeshOptimizer/src/dasMeshOptimizer.h new file mode 100644 index 0000000000..d19f53c0bb --- /dev/null +++ b/modules/dasMeshOptimizer/src/dasMeshOptimizer.h @@ -0,0 +1,13 @@ +#pragma once +#include "daScript/simulate/aot.h" +namespace das { +uint32_t mo_remap(TArray& remap, const TArray& indices, const TArray& vertices, uint32_t stride, Context*, LineInfoArg*); +void mo_remap_vertices(TArray& output, const TArray& vertices, const TArray& remap, uint32_t stride, uint32_t count, Context*, LineInfoArg*); +void mo_remap_indices(TArray& output, const TArray& indices, const TArray& remap, Context*, LineInfoArg*); +void mo_cache(TArray& indices, uint32_t count, Context*, LineInfoArg*); +uint32_t mo_fetch(TArray& indices, TArray& vertices, uint32_t stride, Context*, LineInfoArg*); +void mo_encode_vertices(TArray& output, const TArray& vertices, uint32_t stride, Context*, LineInfoArg*); +bool mo_decode_vertices(TArray& output, const TArray& encoded, uint32_t count, uint32_t stride, Context*, LineInfoArg*); +void mo_encode_indices(TArray& output, const TArray& indices, uint32_t count, Context*, LineInfoArg*); +bool mo_decode_indices(TArray& output, const TArray& encoded, uint32_t count, Context*, LineInfoArg*); +} diff --git a/modules/dasMeshOptimizer/tests/test_allocation_limits.das b/modules/dasMeshOptimizer/tests/test_allocation_limits.das new file mode 100644 index 0000000000..8ea3dfc7bc --- /dev/null +++ b/modules/dasMeshOptimizer/tests/test_allocation_limits.das @@ -0,0 +1,31 @@ +options gen2 +options max_unreserved_size = 65536 +require dastest/testing_boost public +require meshoptimizer + +[test] +def test_meshoptimizer_reserves_large_outputs(t : T?){ + let vertex_count = 20000 + var vertices : array; vertices |> reserve(vertex_count * 4); vertices |> resize(vertex_count * 4) + for (i in range(vertex_count)){ + vertices[i * 4] = uint8(i); vertices[i * 4 + 1] = uint8(i >> 8) + vertices[i * 4 + 2] = uint8(i >> 16); vertices[i * 4 + 3] = uint8(i >> 24) + } + var indices : array; indices |> reserve(18000); indices |> resize(18000) + for (index, i in indices, count()){index = uint(i % vertex_count)} + var remap : array + let compact_count = meshopt_generate_remap(remap, indices, vertices, 4u) + t |> equal(compact_count, uint(length(indices)), "large remap output") + var compact : array; meshopt_remap_vertices(compact, vertices, remap, 4u, compact_count) + var remapped : array; meshopt_remap_indices(remapped, indices, remap) + var encoded_vertices : array; meshopt_encode_vertices(encoded_vertices, compact, 4u) + var decoded_vertices : array + t |> success(meshopt_decode_vertices(decoded_vertices, encoded_vertices, compact_count, 4u), "large vertex decode") + t |> equal(hash(decoded_vertices), hash(compact), "large vertex codec roundtrip") + var encoded_indices : array; meshopt_encode_indices(encoded_indices, remapped, compact_count) + var decoded_indices : array + t |> success(meshopt_decode_indices(decoded_indices, encoded_indices, uint(length(remapped))), "large index decode") + t |> equal(hash(decoded_indices), hash(remapped), "large index codec roundtrip") + delete vertices; delete indices; delete remap; delete compact; delete remapped + delete encoded_vertices; delete decoded_vertices; delete encoded_indices; delete decoded_indices +} diff --git a/modules/dasMeshOptimizer/tests/test_meshoptimizer.das b/modules/dasMeshOptimizer/tests/test_meshoptimizer.das new file mode 100644 index 0000000000..e0fe7cf54e --- /dev/null +++ b/modules/dasMeshOptimizer/tests/test_meshoptimizer.das @@ -0,0 +1,55 @@ +options gen2 +require dastest/testing_boost public +require meshoptimizer +[test] +def test_optimize_and_codec_roundtrip(t : T?){ + var vertices : array; vertices |> resize(64) + for (v in range(4)){for (b in range(16)){vertices[v * 16 + b] = uint8((v % 3) * 20 + b)}} + var indices <- [0u, 1u, 2u, 3u, 1u, 2u] + var remap : array; let count = meshopt_generate_remap(remap, indices, vertices, 16u) + t |> equal(count, 3u, "duplicate vertex removed") + var compact : array; var remapped : array + meshopt_remap_vertices(compact, vertices, remap, 16u, count) + meshopt_remap_indices(remapped, indices, remap) + meshopt_optimize_cache(remapped, count) + t |> equal(meshopt_optimize_fetch(remapped, compact, 16u), 3u) + var encoded, decoded : array + meshopt_encode_vertices(encoded, compact, 16u) + t |> success(meshopt_decode_vertices(decoded, encoded, count, 16u), "vertex decode") + t |> equal(hash(decoded), hash(compact), "codec restores exact packed bytes") + var index_data : array; var index_decoded : array + meshopt_encode_indices(index_data, remapped, count) + t |> success(meshopt_decode_indices(index_decoded, index_data, uint(length(remapped))), "index decode") + for (triangle in range(length(index_decoded) / 3)){ + let i = triangle * 3 + var first = 0 + if (index_decoded[i + 1] < index_decoded[i]){first = 1} + if (index_decoded[i + 2] < index_decoded[i + first]){first = 2} + for (j in range(3)){t |> equal(index_decoded[i + (first + j) % 3], uint(j), "oriented triangle preserved")} + } + var bad <- [0u8, 1u8, 2u8] + t |> success(!meshopt_decode_vertices(decoded, bad, count, 16u), "invalid encoded input is rejected") + t |> equal(length(decoded), 0) + var bad_indices <- clone(index_data); bad_indices |> resize(length(bad_indices) - 1) + delete index_decoded; index_decoded <- [99u] + t |> success(!meshopt_decode_indices(index_decoded, bad_indices, uint(length(remapped))), "truncated index input is rejected") + t |> equal(length(index_decoded), 0, "failed index decode clears output") + var caught_stride = false + try { + meshopt_decode_vertices(decoded, encoded, count, 3u) + } recover { + caught_stride = true + } + t |> success(caught_stride, "invalid codec stride panics") + var out_of_bounds <- [0u, 1u, 4u] + var bad_remap : array; var caught_bounds = false + try { + meshopt_generate_remap(bad_remap, out_of_bounds, vertices, 16u) + } recover { + caught_bounds = true + } + t |> success(caught_bounds, "out-of-range source index panics") + delete vertices; delete indices; delete remap; delete compact; delete remapped + delete encoded; delete decoded; delete index_data; delete index_decoded; delete bad; delete bad_indices + delete out_of_bounds; delete bad_remap +} diff --git a/modules/dasStbImage/.das_module b/modules/dasStbImage/.das_module index bde35ad170..851fbd2616 100644 --- a/modules/dasStbImage/.das_module +++ b/modules/dasStbImage/.das_module @@ -8,6 +8,7 @@ def initialize(project_path : string) { register_dynamic_module("{project_path}/dasModuleStbImage.shared_module", "Module_Raster") register_dynamic_module("{project_path}/dasModuleStbImage.shared_module", "Module_StbTrueType") } + register_native_path("stbimage", "texture_blocks", "{project_path}/stbimage/texture_blocks.das") register_native_path("stbimage", "stbimage_boost", "{project_path}/stbimage/stbimage_boost.das") register_native_path("stbimage", "stbimage_ttf", "{project_path}/stbimage/stbimage_ttf.das") } diff --git a/modules/dasStbImage/CMakeLists.txt b/modules/dasStbImage/CMakeLists.txt index 4f02d6a37e..226601d217 100644 --- a/modules/dasStbImage/CMakeLists.txt +++ b/modules/dasStbImage/CMakeLists.txt @@ -9,6 +9,7 @@ IF ((NOT DAS_STBIMAGE_INCLUDED) AND (NOT ${DAS_STBIMAGE_DISABLED})) # libDasModuleStbImage SET(DAS_STBIMAGE_MODULE_SRC + ${DAS_STBIMAGE_DIR}/src/stb_dxt.h ${DAS_STBIMAGE_DIR}/src/dasStbImage.h ${DAS_STBIMAGE_DIR}/src/dasStbImage_impl.cpp ${DAS_STBIMAGE_DIR}/src/dasStbImage.cpp @@ -26,6 +27,7 @@ IF ((NOT DAS_STBIMAGE_INCLUDED) AND (NOT ${DAS_STBIMAGE_DISABLED})) # AOT-able sources (for test_aot / any consumer); empty when disabled. SET(DASSTBIMAGE_AOT_FILES + modules/dasStbImage/stbimage/texture_blocks.das modules/dasStbImage/stbimage/stbimage_boost.das modules/dasStbImage/stbimage/stbimage_ttf.das ) diff --git a/modules/dasStbImage/README.md b/modules/dasStbImage/README.md new file mode 100644 index 0000000000..72a706f396 --- /dev/null +++ b/modules/dasStbImage/README.md @@ -0,0 +1,24 @@ +# dasStbImage + +The existing image loading, saving and resizing bindings also include `stb_dxt.h` +v1.12. The upstream implementation is vendored unchanged in `src/stb_dxt.h` and +uses its bundled public-domain / MIT licensing options. + +`stb_compress_blocks(pixels, width, height, format, high_quality, output)` accepts +RGBA8 pixels and emits one mip level, with edge replication for partial blocks. +Formats: 0 BC1, 1 BC3, 2 BC4 (R), 3 BC5 (RG). Buffer and dimension validation happens +in the native adapter. The compression algorithm is upstream stb; image policy is +written in daslang. + +`require stbimage/texture_blocks` provides complete mip chains, colour-space-aware +resizing, validation, content-addressed disk caching and CPU decode fallbacks. +Colour textures use sRGB mip generation; data textures use linear mip generation. +Cache keys include image content, dimensions, format, colour space and codec-layer +version. Cache files are written through temporary files and renamed on completion. + +The glTF consumer defaults to BC1 for opaque image data and BC3 when image alpha +is present. BC4/BC5 are available for explicit callers; normal-map-specific RG +reconstruction is not implied by this low-level API. + +Tests: `tests/test_texture_blocks.das` covers mip chains, odd dimensions, cache +reuse/invalidation and alpha preservation. diff --git a/modules/dasStbImage/src/dasStbImage.cpp b/modules/dasStbImage/src/dasStbImage.cpp index 30cce3c548..f1448c1e68 100644 --- a/modules/dasStbImage/src/dasStbImage.cpp +++ b/modules/dasStbImage/src/dasStbImage.cpp @@ -5,6 +5,7 @@ #include "daScript/simulate/bind_enum.h" #include "stb_image.h" +#include "stb_dxt.h" #include "stb_image_write.h" #include "stb_image_resize2.h" @@ -110,6 +111,38 @@ void stbi_write_jpg_to_memory ( int x, int y, int comp, const void * data, int q context->invoke(blk, args, nullptr, at); } +void stb_compress_blocks(const TArray & pixels, int width, int height, int format, bool high_quality, + TArray & output, Context * context, LineInfoArg * at) { + if (width <= 0 || height <= 0 || width > 32768 || height > 32768 || format < 0 || format > 3 || + uint64_t(width)*height*4 != pixels.size || pixels.data == output.data) { + context->throw_error_at(at, "stb_compress_blocks: invalid RGBA image or aliased output"); + } + const int block_size = (format == 0 || format == 2) ? 8 : 16; + const uint64_t size = uint64_t((width+3)/4)*((height+3)/4)*block_size; + if (size > INT_MAX) context->throw_error_at(at, "stb_compress_blocks: image too large"); + builtin_array_reserve(output, int(size), 1, context, at); + builtin_array_resize(output, int(size), 1, context, at); + unsigned char block[64]; + auto * dst = reinterpret_cast(output.data); + for (int y=0; y(*this,lib,"stb_compress_blocks", + SideEffects::modifyArgument,"das::stb_compress_blocks") + ->args({"pixels","width","height","format","high_quality","output","context","at"}); // ---- stb_image: loading ---- addExtern (*this, lib, "stbi_load", SideEffects::worstDefault, "stbi_load") diff --git a/modules/dasStbImage/src/dasStbImage.h b/modules/dasStbImage/src/dasStbImage.h index 04dc366399..0e2f8ef876 100644 --- a/modules/dasStbImage/src/dasStbImage.h +++ b/modules/dasStbImage/src/dasStbImage.h @@ -2,6 +2,7 @@ #include "stb_image.h" +#include "stb_dxt.h" #include "stb_image_write.h" #include "stb_image_resize2.h" @@ -22,6 +23,7 @@ extern "C" { } namespace das { + void stb_compress_blocks(const TArray & pixels, int width, int height, int format, bool high_quality, TArray & output, Context * context, LineInfoArg * at); // write-to-memory wrappers void stbi_write_png_to_memory ( int x, int y, int comp, const void * data, int stride_bytes, const TBlock>> & blk, Context * context, LineInfoArg * at ); diff --git a/modules/dasStbImage/src/dasStbImage_impl.cpp b/modules/dasStbImage/src/dasStbImage_impl.cpp index 0e3bcbca04..bf7ec35ba5 100644 --- a/modules/dasStbImage/src/dasStbImage_impl.cpp +++ b/modules/dasStbImage/src/dasStbImage_impl.cpp @@ -1,5 +1,8 @@ #include "daScript/misc/platform.h" +#define STB_DXT_IMPLEMENTATION +#include "stb_dxt.h" + #define STB_IMAGE_IMPLEMENTATION #include "stb_image.h" #define STB_IMAGE_WRITE_IMPLEMENTATION diff --git a/modules/dasStbImage/src/stb_dxt.h b/modules/dasStbImage/src/stb_dxt.h new file mode 100644 index 0000000000..6150a87f08 --- /dev/null +++ b/modules/dasStbImage/src/stb_dxt.h @@ -0,0 +1,719 @@ +// stb_dxt.h - v1.12 - DXT1/DXT5 compressor - public domain +// original by fabian "ryg" giesen - ported to C by stb +// use '#define STB_DXT_IMPLEMENTATION' before including to create the implementation +// +// USAGE: +// call stb_compress_dxt_block() for every block (you must pad) +// source should be a 4x4 block of RGBA data in row-major order; +// Alpha channel is not stored if you specify alpha=0 (but you +// must supply some constant alpha in the alpha channel). +// You can turn on dithering and "high quality" using mode. +// +// version history: +// v1.12 - (ryg) fix bug in single-color table generator +// v1.11 - (ryg) avoid racy global init, better single-color tables, remove dither +// v1.10 - (i.c) various small quality improvements +// v1.09 - (stb) update documentation re: surprising alpha channel requirement +// v1.08 - (stb) fix bug in dxt-with-alpha block +// v1.07 - (stb) bc4; allow not using libc; add STB_DXT_STATIC +// v1.06 - (stb) fix to known-broken 1.05 +// v1.05 - (stb) support bc5/3dc (Arvids Kokins), use extern "C" in C++ (Pavel Krajcevski) +// v1.04 - (ryg) default to no rounding bias for lerped colors (as per S3TC/DX10 spec); +// single color match fix (allow for inexact color interpolation); +// optimal DXT5 index finder; "high quality" mode that runs multiple refinement steps. +// v1.03 - (stb) endianness support +// v1.02 - (stb) fix alpha encoding bug +// v1.01 - (stb) fix bug converting to RGB that messed up quality, thanks ryg & cbloom +// v1.00 - (stb) first release +// +// contributors: +// Rich Geldreich (more accurate index selection) +// Kevin Schmidt (#defines for "freestanding" compilation) +// github:ppiastucki (BC4 support) +// Ignacio Castano - improve DXT endpoint quantization +// Alan Hickman - static table initialization +// +// LICENSE +// +// See end of file for license information. + +#ifndef STB_INCLUDE_STB_DXT_H +#define STB_INCLUDE_STB_DXT_H + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef STB_DXT_STATIC +#define STBDDEF static +#else +#define STBDDEF extern +#endif + +// compression mode (bitflags) +#define STB_DXT_NORMAL 0 +#define STB_DXT_DITHER 1 // use dithering. was always dubious, now deprecated. does nothing! +#define STB_DXT_HIGHQUAL 2 // high quality mode, does two refinement steps instead of 1. ~30-40% slower. + +STBDDEF void stb_compress_dxt_block(unsigned char *dest, const unsigned char *src_rgba_four_bytes_per_pixel, int alpha, int mode); +STBDDEF void stb_compress_bc4_block(unsigned char *dest, const unsigned char *src_r_one_byte_per_pixel); +STBDDEF void stb_compress_bc5_block(unsigned char *dest, const unsigned char *src_rg_two_byte_per_pixel); + +#define STB_COMPRESS_DXT_BLOCK + +#ifdef __cplusplus +} +#endif +#endif // STB_INCLUDE_STB_DXT_H + +#ifdef STB_DXT_IMPLEMENTATION + +// configuration options for DXT encoder. set them in the project/makefile or just define +// them at the top. + +// STB_DXT_USE_ROUNDING_BIAS +// use a rounding bias during color interpolation. this is closer to what "ideal" +// interpolation would do but doesn't match the S3TC/DX10 spec. old versions (pre-1.03) +// implicitly had this turned on. +// +// in case you're targeting a specific type of hardware (e.g. console programmers): +// NVidia and Intel GPUs (as of 2010) as well as DX9 ref use DXT decoders that are closer +// to STB_DXT_USE_ROUNDING_BIAS. AMD/ATI, S3 and DX10 ref are closer to rounding with no bias. +// you also see "(a*5 + b*3) / 8" on some old GPU designs. +// #define STB_DXT_USE_ROUNDING_BIAS + +#include + +#if !defined(STBD_FABS) +#include +#endif + +#ifndef STBD_FABS +#define STBD_FABS(x) fabs(x) +#endif + +static const unsigned char stb__OMatch5[256][2] = { + { 0, 0 }, { 0, 0 }, { 0, 1 }, { 0, 1 }, { 1, 0 }, { 1, 0 }, { 1, 0 }, { 1, 1 }, + { 1, 1 }, { 1, 1 }, { 1, 2 }, { 0, 4 }, { 2, 1 }, { 2, 1 }, { 2, 1 }, { 2, 2 }, + { 2, 2 }, { 2, 2 }, { 2, 3 }, { 1, 5 }, { 3, 2 }, { 3, 2 }, { 4, 0 }, { 3, 3 }, + { 3, 3 }, { 3, 3 }, { 3, 4 }, { 3, 4 }, { 3, 4 }, { 3, 5 }, { 4, 3 }, { 4, 3 }, + { 5, 2 }, { 4, 4 }, { 4, 4 }, { 4, 5 }, { 4, 5 }, { 5, 4 }, { 5, 4 }, { 5, 4 }, + { 6, 3 }, { 5, 5 }, { 5, 5 }, { 5, 6 }, { 4, 8 }, { 6, 5 }, { 6, 5 }, { 6, 5 }, + { 6, 6 }, { 6, 6 }, { 6, 6 }, { 6, 7 }, { 5, 9 }, { 7, 6 }, { 7, 6 }, { 8, 4 }, + { 7, 7 }, { 7, 7 }, { 7, 7 }, { 7, 8 }, { 7, 8 }, { 7, 8 }, { 7, 9 }, { 8, 7 }, + { 8, 7 }, { 9, 6 }, { 8, 8 }, { 8, 8 }, { 8, 9 }, { 8, 9 }, { 9, 8 }, { 9, 8 }, + { 9, 8 }, { 10, 7 }, { 9, 9 }, { 9, 9 }, { 9, 10 }, { 8, 12 }, { 10, 9 }, { 10, 9 }, + { 10, 9 }, { 10, 10 }, { 10, 10 }, { 10, 10 }, { 10, 11 }, { 9, 13 }, { 11, 10 }, { 11, 10 }, + { 12, 8 }, { 11, 11 }, { 11, 11 }, { 11, 11 }, { 11, 12 }, { 11, 12 }, { 11, 12 }, { 11, 13 }, + { 12, 11 }, { 12, 11 }, { 13, 10 }, { 12, 12 }, { 12, 12 }, { 12, 13 }, { 12, 13 }, { 13, 12 }, + { 13, 12 }, { 13, 12 }, { 14, 11 }, { 13, 13 }, { 13, 13 }, { 13, 14 }, { 12, 16 }, { 14, 13 }, + { 14, 13 }, { 14, 13 }, { 14, 14 }, { 14, 14 }, { 14, 14 }, { 14, 15 }, { 13, 17 }, { 15, 14 }, + { 15, 14 }, { 16, 12 }, { 15, 15 }, { 15, 15 }, { 15, 15 }, { 15, 16 }, { 15, 16 }, { 15, 16 }, + { 15, 17 }, { 16, 15 }, { 16, 15 }, { 17, 14 }, { 16, 16 }, { 16, 16 }, { 16, 17 }, { 16, 17 }, + { 17, 16 }, { 17, 16 }, { 17, 16 }, { 18, 15 }, { 17, 17 }, { 17, 17 }, { 17, 18 }, { 16, 20 }, + { 18, 17 }, { 18, 17 }, { 18, 17 }, { 18, 18 }, { 18, 18 }, { 18, 18 }, { 18, 19 }, { 17, 21 }, + { 19, 18 }, { 19, 18 }, { 20, 16 }, { 19, 19 }, { 19, 19 }, { 19, 19 }, { 19, 20 }, { 19, 20 }, + { 19, 20 }, { 19, 21 }, { 20, 19 }, { 20, 19 }, { 21, 18 }, { 20, 20 }, { 20, 20 }, { 20, 21 }, + { 20, 21 }, { 21, 20 }, { 21, 20 }, { 21, 20 }, { 22, 19 }, { 21, 21 }, { 21, 21 }, { 21, 22 }, + { 20, 24 }, { 22, 21 }, { 22, 21 }, { 22, 21 }, { 22, 22 }, { 22, 22 }, { 22, 22 }, { 22, 23 }, + { 21, 25 }, { 23, 22 }, { 23, 22 }, { 24, 20 }, { 23, 23 }, { 23, 23 }, { 23, 23 }, { 23, 24 }, + { 23, 24 }, { 23, 24 }, { 23, 25 }, { 24, 23 }, { 24, 23 }, { 25, 22 }, { 24, 24 }, { 24, 24 }, + { 24, 25 }, { 24, 25 }, { 25, 24 }, { 25, 24 }, { 25, 24 }, { 26, 23 }, { 25, 25 }, { 25, 25 }, + { 25, 26 }, { 24, 28 }, { 26, 25 }, { 26, 25 }, { 26, 25 }, { 26, 26 }, { 26, 26 }, { 26, 26 }, + { 26, 27 }, { 25, 29 }, { 27, 26 }, { 27, 26 }, { 28, 24 }, { 27, 27 }, { 27, 27 }, { 27, 27 }, + { 27, 28 }, { 27, 28 }, { 27, 28 }, { 27, 29 }, { 28, 27 }, { 28, 27 }, { 29, 26 }, { 28, 28 }, + { 28, 28 }, { 28, 29 }, { 28, 29 }, { 29, 28 }, { 29, 28 }, { 29, 28 }, { 30, 27 }, { 29, 29 }, + { 29, 29 }, { 29, 30 }, { 29, 30 }, { 30, 29 }, { 30, 29 }, { 30, 29 }, { 30, 30 }, { 30, 30 }, + { 30, 30 }, { 30, 31 }, { 30, 31 }, { 31, 30 }, { 31, 30 }, { 31, 30 }, { 31, 31 }, { 31, 31 }, +}; +static const unsigned char stb__OMatch6[256][2] = { + { 0, 0 }, { 0, 1 }, { 1, 0 }, { 1, 1 }, { 1, 1 }, { 1, 2 }, { 2, 1 }, { 2, 2 }, + { 2, 2 }, { 2, 3 }, { 3, 2 }, { 3, 3 }, { 3, 3 }, { 3, 4 }, { 4, 3 }, { 4, 4 }, + { 4, 4 }, { 4, 5 }, { 5, 4 }, { 5, 5 }, { 5, 5 }, { 5, 6 }, { 6, 5 }, { 6, 6 }, + { 6, 6 }, { 6, 7 }, { 7, 6 }, { 7, 7 }, { 7, 7 }, { 7, 8 }, { 8, 7 }, { 8, 8 }, + { 8, 8 }, { 8, 9 }, { 9, 8 }, { 9, 9 }, { 9, 9 }, { 9, 10 }, { 10, 9 }, { 10, 10 }, + { 10, 10 }, { 10, 11 }, { 11, 10 }, { 8, 16 }, { 11, 11 }, { 11, 12 }, { 12, 11 }, { 9, 17 }, + { 12, 12 }, { 12, 13 }, { 13, 12 }, { 11, 16 }, { 13, 13 }, { 13, 14 }, { 14, 13 }, { 12, 17 }, + { 14, 14 }, { 14, 15 }, { 15, 14 }, { 14, 16 }, { 15, 15 }, { 15, 16 }, { 16, 14 }, { 16, 15 }, + { 17, 14 }, { 16, 16 }, { 16, 17 }, { 17, 16 }, { 18, 15 }, { 17, 17 }, { 17, 18 }, { 18, 17 }, + { 20, 14 }, { 18, 18 }, { 18, 19 }, { 19, 18 }, { 21, 15 }, { 19, 19 }, { 19, 20 }, { 20, 19 }, + { 20, 20 }, { 20, 20 }, { 20, 21 }, { 21, 20 }, { 21, 21 }, { 21, 21 }, { 21, 22 }, { 22, 21 }, + { 22, 22 }, { 22, 22 }, { 22, 23 }, { 23, 22 }, { 23, 23 }, { 23, 23 }, { 23, 24 }, { 24, 23 }, + { 24, 24 }, { 24, 24 }, { 24, 25 }, { 25, 24 }, { 25, 25 }, { 25, 25 }, { 25, 26 }, { 26, 25 }, + { 26, 26 }, { 26, 26 }, { 26, 27 }, { 27, 26 }, { 24, 32 }, { 27, 27 }, { 27, 28 }, { 28, 27 }, + { 25, 33 }, { 28, 28 }, { 28, 29 }, { 29, 28 }, { 27, 32 }, { 29, 29 }, { 29, 30 }, { 30, 29 }, + { 28, 33 }, { 30, 30 }, { 30, 31 }, { 31, 30 }, { 30, 32 }, { 31, 31 }, { 31, 32 }, { 32, 30 }, + { 32, 31 }, { 33, 30 }, { 32, 32 }, { 32, 33 }, { 33, 32 }, { 34, 31 }, { 33, 33 }, { 33, 34 }, + { 34, 33 }, { 36, 30 }, { 34, 34 }, { 34, 35 }, { 35, 34 }, { 37, 31 }, { 35, 35 }, { 35, 36 }, + { 36, 35 }, { 36, 36 }, { 36, 36 }, { 36, 37 }, { 37, 36 }, { 37, 37 }, { 37, 37 }, { 37, 38 }, + { 38, 37 }, { 38, 38 }, { 38, 38 }, { 38, 39 }, { 39, 38 }, { 39, 39 }, { 39, 39 }, { 39, 40 }, + { 40, 39 }, { 40, 40 }, { 40, 40 }, { 40, 41 }, { 41, 40 }, { 41, 41 }, { 41, 41 }, { 41, 42 }, + { 42, 41 }, { 42, 42 }, { 42, 42 }, { 42, 43 }, { 43, 42 }, { 40, 48 }, { 43, 43 }, { 43, 44 }, + { 44, 43 }, { 41, 49 }, { 44, 44 }, { 44, 45 }, { 45, 44 }, { 43, 48 }, { 45, 45 }, { 45, 46 }, + { 46, 45 }, { 44, 49 }, { 46, 46 }, { 46, 47 }, { 47, 46 }, { 46, 48 }, { 47, 47 }, { 47, 48 }, + { 48, 46 }, { 48, 47 }, { 49, 46 }, { 48, 48 }, { 48, 49 }, { 49, 48 }, { 50, 47 }, { 49, 49 }, + { 49, 50 }, { 50, 49 }, { 52, 46 }, { 50, 50 }, { 50, 51 }, { 51, 50 }, { 53, 47 }, { 51, 51 }, + { 51, 52 }, { 52, 51 }, { 52, 52 }, { 52, 52 }, { 52, 53 }, { 53, 52 }, { 53, 53 }, { 53, 53 }, + { 53, 54 }, { 54, 53 }, { 54, 54 }, { 54, 54 }, { 54, 55 }, { 55, 54 }, { 55, 55 }, { 55, 55 }, + { 55, 56 }, { 56, 55 }, { 56, 56 }, { 56, 56 }, { 56, 57 }, { 57, 56 }, { 57, 57 }, { 57, 57 }, + { 57, 58 }, { 58, 57 }, { 58, 58 }, { 58, 58 }, { 58, 59 }, { 59, 58 }, { 59, 59 }, { 59, 59 }, + { 59, 60 }, { 60, 59 }, { 60, 60 }, { 60, 60 }, { 60, 61 }, { 61, 60 }, { 61, 61 }, { 61, 61 }, + { 61, 62 }, { 62, 61 }, { 62, 62 }, { 62, 62 }, { 62, 63 }, { 63, 62 }, { 63, 63 }, { 63, 63 }, +}; + +static int stb__Mul8Bit(int a, int b) +{ + int t = a*b + 128; + return (t + (t >> 8)) >> 8; +} + +static void stb__From16Bit(unsigned char *out, unsigned short v) +{ + int rv = (v & 0xf800) >> 11; + int gv = (v & 0x07e0) >> 5; + int bv = (v & 0x001f) >> 0; + + // expand to 8 bits via bit replication + out[0] = (rv * 33) >> 2; + out[1] = (gv * 65) >> 4; + out[2] = (bv * 33) >> 2; + out[3] = 0; +} + +static unsigned short stb__As16Bit(int r, int g, int b) +{ + return (unsigned short)((stb__Mul8Bit(r,31) << 11) + (stb__Mul8Bit(g,63) << 5) + stb__Mul8Bit(b,31)); +} + +// linear interpolation at 1/3 point between a and b, using desired rounding type +static int stb__Lerp13(int a, int b) +{ +#ifdef STB_DXT_USE_ROUNDING_BIAS + // with rounding bias + return a + stb__Mul8Bit(b-a, 0x55); +#else + // without rounding bias + // replace "/ 3" by "* 0xaaab) >> 17" if your compiler sucks or you really need every ounce of speed. + return (2*a + b) / 3; +#endif +} + +// lerp RGB color +static void stb__Lerp13RGB(unsigned char *out, unsigned char *p1, unsigned char *p2) +{ + out[0] = (unsigned char)stb__Lerp13(p1[0], p2[0]); + out[1] = (unsigned char)stb__Lerp13(p1[1], p2[1]); + out[2] = (unsigned char)stb__Lerp13(p1[2], p2[2]); +} + +/****************************************************************************/ + +static void stb__EvalColors(unsigned char *color,unsigned short c0,unsigned short c1) +{ + stb__From16Bit(color+ 0, c0); + stb__From16Bit(color+ 4, c1); + stb__Lerp13RGB(color+ 8, color+0, color+4); + stb__Lerp13RGB(color+12, color+4, color+0); +} + +// The color matching function +static unsigned int stb__MatchColorsBlock(unsigned char *block, unsigned char *color) +{ + unsigned int mask = 0; + int dirr = color[0*4+0] - color[1*4+0]; + int dirg = color[0*4+1] - color[1*4+1]; + int dirb = color[0*4+2] - color[1*4+2]; + int dots[16]; + int stops[4]; + int i; + int c0Point, halfPoint, c3Point; + + for(i=0;i<16;i++) + dots[i] = block[i*4+0]*dirr + block[i*4+1]*dirg + block[i*4+2]*dirb; + + for(i=0;i<4;i++) + stops[i] = color[i*4+0]*dirr + color[i*4+1]*dirg + color[i*4+2]*dirb; + + // think of the colors as arranged on a line; project point onto that line, then choose + // next color out of available ones. we compute the crossover points for "best color in top + // half"/"best in bottom half" and then the same inside that subinterval. + // + // relying on this 1d approximation isn't always optimal in terms of euclidean distance, + // but it's very close and a lot faster. + // http://cbloomrants.blogspot.com/2008/12/12-08-08-dxtc-summary.html + + c0Point = (stops[1] + stops[3]); + halfPoint = (stops[3] + stops[2]); + c3Point = (stops[2] + stops[0]); + + for (i=15;i>=0;i--) { + int dot = dots[i]*2; + mask <<= 2; + + if(dot < halfPoint) + mask |= (dot < c0Point) ? 1 : 3; + else + mask |= (dot < c3Point) ? 2 : 0; + } + + return mask; +} + +// The color optimization function. (Clever code, part 1) +static void stb__OptimizeColorsBlock(unsigned char *block, unsigned short *pmax16, unsigned short *pmin16) +{ + int mind,maxd; + unsigned char *minp, *maxp; + double magn; + int v_r,v_g,v_b; + static const int nIterPower = 4; + float covf[6],vfr,vfg,vfb; + + // determine color distribution + int cov[6]; + int mu[3],min[3],max[3]; + int ch,i,iter; + + for(ch=0;ch<3;ch++) + { + const unsigned char *bp = ((const unsigned char *) block) + ch; + int muv,minv,maxv; + + muv = minv = maxv = bp[0]; + for(i=4;i<64;i+=4) + { + muv += bp[i]; + if (bp[i] < minv) minv = bp[i]; + else if (bp[i] > maxv) maxv = bp[i]; + } + + mu[ch] = (muv + 8) >> 4; + min[ch] = minv; + max[ch] = maxv; + } + + // determine covariance matrix + for (i=0;i<6;i++) + cov[i] = 0; + + for (i=0;i<16;i++) + { + int r = block[i*4+0] - mu[0]; + int g = block[i*4+1] - mu[1]; + int b = block[i*4+2] - mu[2]; + + cov[0] += r*r; + cov[1] += r*g; + cov[2] += r*b; + cov[3] += g*g; + cov[4] += g*b; + cov[5] += b*b; + } + + // convert covariance matrix to float, find principal axis via power iter + for(i=0;i<6;i++) + covf[i] = cov[i] / 255.0f; + + vfr = (float) (max[0] - min[0]); + vfg = (float) (max[1] - min[1]); + vfb = (float) (max[2] - min[2]); + + for(iter=0;iter magn) magn = STBD_FABS(vfg); + if (STBD_FABS(vfb) > magn) magn = STBD_FABS(vfb); + + if(magn < 4.0f) { // too small, default to luminance + v_r = 299; // JPEG YCbCr luma coefs, scaled by 1000. + v_g = 587; + v_b = 114; + } else { + magn = 512.0 / magn; + v_r = (int) (vfr * magn); + v_g = (int) (vfg * magn); + v_b = (int) (vfb * magn); + } + + minp = maxp = block; + mind = maxd = block[0]*v_r + block[1]*v_g + block[2]*v_b; + // Pick colors at extreme points + for(i=1;i<16;i++) + { + int dot = block[i*4+0]*v_r + block[i*4+1]*v_g + block[i*4+2]*v_b; + + if (dot < mind) { + mind = dot; + minp = block+i*4; + } + + if (dot > maxd) { + maxd = dot; + maxp = block+i*4; + } + } + + *pmax16 = stb__As16Bit(maxp[0],maxp[1],maxp[2]); + *pmin16 = stb__As16Bit(minp[0],minp[1],minp[2]); +} + +static const float stb__midpoints5[32] = { + 0.015686f, 0.047059f, 0.078431f, 0.111765f, 0.145098f, 0.176471f, 0.207843f, 0.241176f, 0.274510f, 0.305882f, 0.337255f, 0.370588f, 0.403922f, 0.435294f, 0.466667f, 0.5f, + 0.533333f, 0.564706f, 0.596078f, 0.629412f, 0.662745f, 0.694118f, 0.725490f, 0.758824f, 0.792157f, 0.823529f, 0.854902f, 0.888235f, 0.921569f, 0.952941f, 0.984314f, 1.0f +}; + +static const float stb__midpoints6[64] = { + 0.007843f, 0.023529f, 0.039216f, 0.054902f, 0.070588f, 0.086275f, 0.101961f, 0.117647f, 0.133333f, 0.149020f, 0.164706f, 0.180392f, 0.196078f, 0.211765f, 0.227451f, 0.245098f, + 0.262745f, 0.278431f, 0.294118f, 0.309804f, 0.325490f, 0.341176f, 0.356863f, 0.372549f, 0.388235f, 0.403922f, 0.419608f, 0.435294f, 0.450980f, 0.466667f, 0.482353f, 0.500000f, + 0.517647f, 0.533333f, 0.549020f, 0.564706f, 0.580392f, 0.596078f, 0.611765f, 0.627451f, 0.643137f, 0.658824f, 0.674510f, 0.690196f, 0.705882f, 0.721569f, 0.737255f, 0.754902f, + 0.772549f, 0.788235f, 0.803922f, 0.819608f, 0.835294f, 0.850980f, 0.866667f, 0.882353f, 0.898039f, 0.913725f, 0.929412f, 0.945098f, 0.960784f, 0.976471f, 0.992157f, 1.0f +}; + +static unsigned short stb__Quantize5(float x) +{ + unsigned short q; + x = x < 0 ? 0 : x > 1 ? 1 : x; // saturate + q = (unsigned short)(x * 31); + q += (x > stb__midpoints5[q]); + return q; +} + +static unsigned short stb__Quantize6(float x) +{ + unsigned short q; + x = x < 0 ? 0 : x > 1 ? 1 : x; // saturate + q = (unsigned short)(x * 63); + q += (x > stb__midpoints6[q]); + return q; +} + +// The refinement function. (Clever code, part 2) +// Tries to optimize colors to suit block contents better. +// (By solving a least squares system via normal equations+Cramer's rule) +static int stb__RefineBlock(unsigned char *block, unsigned short *pmax16, unsigned short *pmin16, unsigned int mask) +{ + static const int w1Tab[4] = { 3,0,2,1 }; + static const int prods[4] = { 0x090000,0x000900,0x040102,0x010402 }; + // ^some magic to save a lot of multiplies in the accumulating loop... + // (precomputed products of weights for least squares system, accumulated inside one 32-bit register) + + float f; + unsigned short oldMin, oldMax, min16, max16; + int i, akku = 0, xx,xy,yy; + int At1_r,At1_g,At1_b; + int At2_r,At2_g,At2_b; + unsigned int cm = mask; + + oldMin = *pmin16; + oldMax = *pmax16; + + if((mask ^ (mask<<2)) < 4) // all pixels have the same index? + { + // yes, linear system would be singular; solve using optimal + // single-color match on average color + int r = 8, g = 8, b = 8; + for (i=0;i<16;++i) { + r += block[i*4+0]; + g += block[i*4+1]; + b += block[i*4+2]; + } + + r >>= 4; g >>= 4; b >>= 4; + + max16 = (stb__OMatch5[r][0]<<11) | (stb__OMatch6[g][0]<<5) | stb__OMatch5[b][0]; + min16 = (stb__OMatch5[r][1]<<11) | (stb__OMatch6[g][1]<<5) | stb__OMatch5[b][1]; + } else { + At1_r = At1_g = At1_b = 0; + At2_r = At2_g = At2_b = 0; + for (i=0;i<16;++i,cm>>=2) { + int step = cm&3; + int w1 = w1Tab[step]; + int r = block[i*4+0]; + int g = block[i*4+1]; + int b = block[i*4+2]; + + akku += prods[step]; + At1_r += w1*r; + At1_g += w1*g; + At1_b += w1*b; + At2_r += r; + At2_g += g; + At2_b += b; + } + + At2_r = 3*At2_r - At1_r; + At2_g = 3*At2_g - At1_g; + At2_b = 3*At2_b - At1_b; + + // extract solutions and decide solvability + xx = akku >> 16; + yy = (akku >> 8) & 0xff; + xy = (akku >> 0) & 0xff; + + f = 3.0f / 255.0f / (xx*yy - xy*xy); + + max16 = stb__Quantize5((At1_r*yy - At2_r * xy) * f) << 11; + max16 |= stb__Quantize6((At1_g*yy - At2_g * xy) * f) << 5; + max16 |= stb__Quantize5((At1_b*yy - At2_b * xy) * f) << 0; + + min16 = stb__Quantize5((At2_r*xx - At1_r * xy) * f) << 11; + min16 |= stb__Quantize6((At2_g*xx - At1_g * xy) * f) << 5; + min16 |= stb__Quantize5((At2_b*xx - At1_b * xy) * f) << 0; + } + + *pmin16 = min16; + *pmax16 = max16; + return oldMin != min16 || oldMax != max16; +} + +// Color block compression +static void stb__CompressColorBlock(unsigned char *dest, unsigned char *block, int mode) +{ + unsigned int mask; + int i; + int refinecount; + unsigned short max16, min16; + unsigned char color[4*4]; + + refinecount = (mode & STB_DXT_HIGHQUAL) ? 2 : 1; + + // check if block is constant + for (i=1;i<16;i++) + if (((unsigned int *) block)[i] != ((unsigned int *) block)[0]) + break; + + if(i == 16) { // constant color + int r = block[0], g = block[1], b = block[2]; + mask = 0xaaaaaaaa; + max16 = (stb__OMatch5[r][0]<<11) | (stb__OMatch6[g][0]<<5) | stb__OMatch5[b][0]; + min16 = (stb__OMatch5[r][1]<<11) | (stb__OMatch6[g][1]<<5) | stb__OMatch5[b][1]; + } else { + // first step: PCA+map along principal axis + stb__OptimizeColorsBlock(block,&max16,&min16); + if (max16 != min16) { + stb__EvalColors(color,max16,min16); + mask = stb__MatchColorsBlock(block,color); + } else + mask = 0; + + // third step: refine (multiple times if requested) + for (i=0;i> 8); + dest[2] = (unsigned char) (min16); + dest[3] = (unsigned char) (min16 >> 8); + dest[4] = (unsigned char) (mask); + dest[5] = (unsigned char) (mask >> 8); + dest[6] = (unsigned char) (mask >> 16); + dest[7] = (unsigned char) (mask >> 24); +} + +// Alpha block compression (this is easy for a change) +static void stb__CompressAlphaBlock(unsigned char *dest,unsigned char *src, int stride) +{ + int i,dist,bias,dist4,dist2,bits,mask; + + // find min/max color + int mn,mx; + mn = mx = src[0]; + + for (i=1;i<16;i++) + { + if (src[i*stride] < mn) mn = src[i*stride]; + else if (src[i*stride] > mx) mx = src[i*stride]; + } + + // encode them + dest[0] = (unsigned char)mx; + dest[1] = (unsigned char)mn; + dest += 2; + + // determine bias and emit color indices + // given the choice of mx/mn, these indices are optimal: + // http://fgiesen.wordpress.com/2009/12/15/dxt5-alpha-block-index-determination/ + dist = mx-mn; + dist4 = dist*4; + dist2 = dist*2; + bias = (dist < 8) ? (dist - 1) : (dist/2 + 2); + bias -= mn * 7; + bits = 0,mask=0; + + for (i=0;i<16;i++) { + int a = src[i*stride]*7 + bias; + int ind,t; + + // select index. this is a "linear scale" lerp factor between 0 (val=min) and 7 (val=max). + t = (a >= dist4) ? -1 : 0; ind = t & 4; a -= dist4 & t; + t = (a >= dist2) ? -1 : 0; ind += t & 2; a -= dist2 & t; + ind += (a >= dist); + + // turn linear scale into DXT index (0/1 are extremal pts) + ind = -ind & 7; + ind ^= (2 > ind); + + // write index + mask |= ind << bits; + if((bits += 3) >= 8) { + *dest++ = (unsigned char)mask; + mask >>= 8; + bits -= 8; + } + } +} + +void stb_compress_dxt_block(unsigned char *dest, const unsigned char *src, int alpha, int mode) +{ + unsigned char data[16][4]; + if (alpha) { + int i; + stb__CompressAlphaBlock(dest,(unsigned char*) src+3, 4); + dest += 8; + // make a new copy of the data in which alpha is opaque, + // because code uses a fast test for color constancy + memcpy(data, src, 4*16); + for (i=0; i < 16; ++i) + data[i][3] = 255; + src = &data[0][0]; + } + + stb__CompressColorBlock(dest,(unsigned char*) src,mode); +} + +void stb_compress_bc4_block(unsigned char *dest, const unsigned char *src) +{ + stb__CompressAlphaBlock(dest,(unsigned char*) src, 1); +} + +void stb_compress_bc5_block(unsigned char *dest, const unsigned char *src) +{ + stb__CompressAlphaBlock(dest,(unsigned char*) src,2); + stb__CompressAlphaBlock(dest + 8,(unsigned char*) src+1,2); +} +#endif // STB_DXT_IMPLEMENTATION + +// Compile with STB_DXT_IMPLEMENTATION and STB_DXT_GENERATE_TABLES +// defined to generate the tables above. +#ifdef STB_DXT_GENERATE_TABLES +#include + +int main() +{ + int i, j; + const char *omatch_names[] = { "stb__OMatch5", "stb__OMatch6" }; + int dequant_mults[2] = { 33*4, 65 }; // .4 fixed-point dequant multipliers + + // optimal endpoint tables + for (i = 0; i < 2; ++i) { + int dequant = dequant_mults[i]; + int size = i ? 64 : 32; + printf("static const unsigned char %s[256][2] = {\n", omatch_names[i]); + for (int j = 0; j < 256; ++j) { + int mn, mx; + int best_mn = 0, best_mx = 0; + int best_err = 256 * 100; + for (mn=0;mn> 4; + int maxe = (mx * dequant) >> 4; + int err = abs(stb__Lerp13(maxe, mine) - j) * 100; + + // DX10 spec says that interpolation must be within 3% of "correct" result, + // add this as error term. Normally we'd expect a random distribution of + // +-1.5% error, but nowhere in the spec does it say that the error has to be + // unbiased - better safe than sorry. + err += abs(maxe - mine) * 3; + + if(err < best_err) { + best_mn = mn; + best_mx = mx; + best_err = err; + } + } + } + if ((j % 8) == 0) printf(" "); // 2 spaces, third is done below + printf(" { %2d, %2d },", best_mx, best_mn); + if ((j % 8) == 7) printf("\n"); + } + printf("};\n"); + } + + return 0; +} +#endif + +/* +------------------------------------------------------------------------------ +This software is available under 2 licenses -- choose whichever you prefer. +------------------------------------------------------------------------------ +ALTERNATIVE A - MIT License +Copyright (c) 2017 Sean Barrett +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +------------------------------------------------------------------------------ +ALTERNATIVE B - Public Domain (www.unlicense.org) +This is free and unencumbered software released into the public domain. +Anyone is free to copy, modify, publish, use, compile, sell, or distribute this +software, either in source code form or as a compiled binary, for any purpose, +commercial or non-commercial, and by any means. +In jurisdictions that recognize copyright laws, the author or authors of this +software dedicate any and all copyright interest in the software to the public +domain. We make this dedication for the benefit of the public at large and to +the detriment of our heirs and successors. We intend this dedication to be an +overt act of relinquishment in perpetuity of all present and future rights to +this software under copyright law. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------------ +*/ diff --git a/modules/dasStbImage/src/stb_dxt.version b/modules/dasStbImage/src/stb_dxt.version new file mode 100644 index 0000000000..d680099263 --- /dev/null +++ b/modules/dasStbImage/src/stb_dxt.version @@ -0,0 +1,4 @@ +Upstream: https://github.com/nothings/stb/blob/master/stb_dxt.h +Version: 1.12 +SHA256: 807667ef98e0fd749cdb65cca0c2d980bc148109d2fed6f1873c81ae0f449933 +Vendored: 2026-09-14 diff --git a/modules/dasStbImage/stbimage/texture_blocks.das b/modules/dasStbImage/stbimage/texture_blocks.das new file mode 100644 index 0000000000..f60eb46bb6 --- /dev/null +++ b/modules/dasStbImage/stbimage/texture_blocks.das @@ -0,0 +1,123 @@ +options gen2 +module texture_blocks shared public +require stbimage +require math +require daslib/fio + +let BLOCK_TEXTURE_VERSION = 1 +struct BlockMip { + //! One block-compressed mip level and its logical pixel dimensions. + width, height : int //! Logical width and height; edge blocks are padded by the compressor. + data : array //! Encoded BC block bytes in row-major block order. +} +struct BlockTexture { + //! A complete BC-compressed texture with a deterministic content key and mip chain. + version : int //! Serialization layout version; must equal `BLOCK_TEXTURE_VERSION`. + width, height, format : int //! Base dimensions and format code: BC1, BC3, BC4, or BC5 (0..3). + srgb : bool //! Whether mip reduction treats RGB input as sRGB-encoded color. + key : uint64 //! Content-derived cache key including dimensions, format, color space, and version. + mips : array //! Full mip chain from the base level through 1x1. +} +def block_texture_valid(t : BlockTexture) : bool { + //! Return true when version, dimensions, format, mip count, and every encoded byte size + //! satisfy the serialized block-texture contract. This does not decode or assess image quality. + if (t.version != BLOCK_TEXTURE_VERSION || t.width < 1 || t.height < 1 || t.width > 32768 || t.height > 32768 || t.format < 0 || t.format > 3){return false} + var w = t.width; var h = t.height; var count = 0 + while (true){count++; if (w == 1 && h == 1){break}; w = max(1, w / 2); h = max(1, h / 2)} + if (length(t.mips) != count){return false} + w = t.width; h = t.height + for (m in t.mips){ + let expected = ((w + 3) / 4) * ((h + 3) / 4) * ((t.format == 0 || t.format == 2) ? 8 : 16) + if (m.width != w || m.height != h || length(m.data) != expected){return false} + w = max(1, w / 2); h = max(1, h / 2) + } + return true +} +def block_texture_key(pixels : array; width, height, format : int; srgb : bool) : uint64 { + //! Compute the deterministic cache key for tightly packed RGBA8 pixels and their compression + //! settings. Callers must include exactly the same metadata later used for compression. + return hash(pixels) ^ hash((width = width, height = height, format = format, srgb = srgb, version = BLOCK_TEXTURE_VERSION)) +} +def compress_block_texture(pixels : array; width, height, format : int; srgb : bool) : BlockTexture { + //! Compress tightly packed RGBA8 pixels into BC1/BC3/BC4/BC5 (`format` 0..3), generating a + //! box-filtered mip chain through 1x1. Panics when dimensions do not match the input byte count. + if (width < 1 || height < 1 || int64(width) * int64(height) * 4l != long_length(pixels)){panic("Invalid texture pixels")} + var result = BlockTexture(version = BLOCK_TEXTURE_VERSION, width = width, height = height, format = format, srgb = srgb, key = block_texture_key(pixels, width, height, format, srgb)) + var current <- clone(pixels); var w = width; var h = height + while (true){ + var mip = BlockMip(width = w, height = h) + stb_compress_blocks(current, w, h, format, true, mip.data) + result.mips |> emplace(mip) + if (w == 1 && h == 1){break} + let nw = max(1, w / 2); let nh = max(1, h / 2) + let smaller_size = int64(nw) * int64(nh) * 4l + var smaller : array; smaller |> reserve(smaller_size); smaller |> resize(smaller_size) + unsafe{ + stbir_resize(addr(current[0]), w, h, 0, addr(smaller[0]), nw, nh, 0, + stbir_pixel_layout.STBIR_RGBA, srgb ? stbir_datatype.STBIR_TYPE_UINT8_SRGB : stbir_datatype.STBIR_TYPE_UINT8, + stbir_edge.STBIR_EDGE_CLAMP, stbir_filter.STBIR_FILTER_BOX) + } + delete current; current <- smaller; w = nw; h = nh + } + delete current; return <- result +} +def cached_block_texture(pixels : array; width, height, format : int; srgb : bool; directory : string; var hit : bool&) : BlockTexture { + //! Load a valid content-keyed `.das_tex` entry from `directory`, or compress and atomically + //! populate it on a miss. Sets `hit` only for a valid matching entry; an empty directory disables I/O. + let key = block_texture_key(pixels, width, height, format, srgb) + let file = "{directory}/{key}.das_tex" + var result : BlockTexture; hit = false + if (!empty(directory)){fopen(file, "rb") $(f){if (f != null){try{hit = fload(f, result); if (hit){hit = block_texture_valid(result) && result.key == key}}recover{hit = false}}}} + if (hit){return <- result} + delete result; result <- compress_block_texture(pixels, width, height, format, srgb) + if (!empty(directory)){ + mkdir_rec(directory) + let temporary = "{file}.tmp-{ref_time_ticks()}" + var written = false + fopen(temporary, "wb") $(f){if (f != null){written = fsave(f, result) > 0}} + if (written){var error : string; if (!rename(temporary, file, error)){remove(temporary)}} + } + return <- result +} +def private block_u16(data : array; at : int) : uint {return uint(data[at]) | (uint(data[at + 1]) << 8)} +def private block_color(v : uint) : uint4 { + let r = (v >> 11) & 31u; let g = (v >> 5) & 63u; let b = v & 31u + return uint4((r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2), 255u) +} +def private block_channel(data : array; at, pixel : int) : uint { + let a = uint(data[at]); let b = uint(data[at + 1]); var bits = 0ul + for (i in range(6)){bits |= uint64(data[at + 2 + i]) << (uint64(i) * 8ul)} + let code = uint((bits >> (uint64(pixel) * 3ul)) & 7ul) + if (code == 0u){return a}; if (code == 1u){return b} + if (a > b){return ((8u - code) * a + (code - 1u) * b) / 7u} + if (code == 6u){return 0u}; if (code == 7u){return 255u} + return ((6u - code) * a + (code - 1u) * b) / 5u +} +def decode_block_mip(texture : BlockTexture; level : int) : array { + //! Decode one BC mip to tightly packed RGBA8 pixels. Panics if the texture contract is invalid + //! or `level` is outside the stored mip chain. + if (!block_texture_valid(texture) || level < 0 || level >= length(texture.mips)){panic("Invalid compressed texture")} + let m & = unsafe(texture.mips[level]); let result_size = int64(m.width) * int64(m.height) * 4l + var result : array; result |> reserve(result_size); result |> resize(result_size) + let stride = (texture.format == 0 || texture.format == 2) ? 8 : 16 + for (y in range(m.height)){for (x in range(m.width)){ + let at = ((y / 4) * ((m.width + 3) / 4) + x / 4) * stride; let pixel = (y % 4) * 4 + x % 4 + var color = uint4(0u, 0u, 0u, 255u) + if (texture.format < 2){ + let rgb = at + (texture.format == 1 ? 8 : 0) + let c0 = block_u16(m.data, rgb); let c1 = block_u16(m.data, rgb + 2) + let a = block_color(c0); let b = block_color(c1) + let code = (uint(m.data[rgb + 4 + pixel / 4]) >> uint((pixel % 4) * 2)) & 3u + if (code == 0u){color = a}elif (code == 1u){color = b} + elif (c0 > c1 || texture.format == 1){color = code == 2u ? (a * 2u + b) / 3u : (a + b * 2u) / 3u} + else{color = code == 2u ? (a + b) / 2u : uint4(0u)} + if (texture.format == 1){color.w = block_channel(m.data, at, pixel)} + }else{ + color.x = block_channel(m.data, at, pixel) + if (texture.format == 3){color.y = block_channel(m.data, at + 8, pixel)} + } + let out = (y * m.width + x) * 4 + for (c in range(4)){result[out + c] = uint8(color[c])} + }} + return <- result +} diff --git a/modules/dasStbImage/tests/test_allocation_limits.das b/modules/dasStbImage/tests/test_allocation_limits.das new file mode 100644 index 0000000000..6985b4676b --- /dev/null +++ b/modules/dasStbImage/tests/test_allocation_limits.das @@ -0,0 +1,22 @@ +options gen2 +options max_unreserved_size = 65536 +require dastest/testing_boost public +require stbimage/texture_blocks + +[test] +def test_block_textures_reserve_large_outputs(t : T?){ + let width = 256; let height = 512 + var pixels : array; pixels |> reserve(width * height * 4); pixels |> resize(width * height * 4) + for (i in range(width * height)){ + pixels[i * 4] = uint8(i); pixels[i * 4 + 1] = uint8(i >> 3) + pixels[i * 4 + 2] = uint8(i >> 7); pixels[i * 4 + 3] = uint8(i) + } + for (format in [0, 1]){ + var texture <- compress_block_texture(pixels, width, height, format, false) + t |> success(block_texture_valid(texture), "large BC texture and mip chain") + var decoded <- decode_block_mip(texture, 0) + t |> equal(length(decoded), width * height * 4, "large decoded RGBA output") + delete texture; delete decoded + } + delete pixels +} diff --git a/modules/dasStbImage/tests/test_texture_blocks.das b/modules/dasStbImage/tests/test_texture_blocks.das new file mode 100644 index 0000000000..8fc6936f0c --- /dev/null +++ b/modules/dasStbImage/tests/test_texture_blocks.das @@ -0,0 +1,73 @@ +options gen2 +require dastest/testing_boost public +require stbimage/texture_blocks +require math +require daslib/fio +[test] +def test_block_mips_cache_and_decode(t : T?){ + var pixels : array; pixels |> resize(7 * 5 * 4) + for (i in range(35)){pixels[i * 4] = 80u8; pixels[i * 4 + 1] = 120u8; pixels[i * 4 + 2] = 180u8; pixels[i * 4 + 3] = 255u8} + var image <- compress_block_texture(pixels, 7, 5, 0, true) + t |> success(block_texture_valid(image), "odd dimensions have complete valid mip chains") + t |> equal(length(image.mips), 3) + var decoded <- decode_block_mip(image, 0) + for (pixel, decoded_pixel in pixels, decoded){t |> success(abs(int(pixel) - int(decoded_pixel)) <= 6, "solid colour survives BC1")} + var hit = false + let dir_r = create_temp_directory_result("texture_blocks") + if (!(dir_r is value)){t |> failure("create texture cache directory"); delete pixels; delete image; delete decoded; return } + let dir = unsafe(dir_r.value) + var first <- cached_block_texture(pixels, 7, 5, 0, true, dir, hit) + var second <- cached_block_texture(pixels, 7, 5, 0, true, dir, hit) + t |> success(hit, "second lookup reads cached compressed data") + t |> equal(first.key, second.key) + let cache_file = "{dir}/{first.key}.das_tex" + t |> success(fwrite(cache_file, "corrupt"), "replace cache entry with malformed data") + var rebuilt <- cached_block_texture(pixels, 7, 5, 0, true, dir, hit) + t |> success(!hit, "corrupt cache entry is rebuilt") + t |> success(block_texture_valid(rebuilt) && rebuilt.key == first.key, "rebuilt cache entry is valid") + t |> success(block_texture_key(pixels, 7, 5, 0, false) != first.key, "colour-space policy affects cache identity") + pixels[0] = 10u8 + t |> success(block_texture_key(pixels, 7, 5, 0, true) != first.key, "content affects cache identity") + rmdir_rec_result(dir) + delete pixels; delete image; delete decoded; delete first; delete second; delete rebuilt +} + +[test] +def test_block_texture_rejects_bad_pixel_length(t : T?){ + var pixels : array; pixels |> resize(4 * 4 * 4 - 1) + var rejected = false + try { + var image <- compress_block_texture(pixels, 4, 4, 0, false) + delete image + } recover { + rejected = true + } + t |> success(rejected, "compression rejects pixel buffers that do not match dimensions") + delete pixels +} +[test] +def test_bc3_preserves_alpha(t : T?){ + var pixels : array; pixels |> resize(64) + for (i in range(16)){pixels[i * 4] = 180u8; pixels[i * 4 + 1] = 90u8; pixels[i * 4 + 2] = 30u8; pixels[i * 4 + 3] = uint8(i * 17)} + var image <- compress_block_texture(pixels, 4, 4, 1, true) + var decoded <- decode_block_mip(image, 0) + for (i in range(16)){t |> success(abs(int(decoded[i * 4 + 3]) - i * 17) <= 20, "BC3 alpha gradient survives")} + t |> equal(decoded[3], 0u8); t |> equal(decoded[63], 255u8) + delete pixels; delete image; delete decoded +} + +[test] +def test_bc4_and_bc5_channels(t : T?){ + var pixels : array; pixels |> resize(64) + for (i in range(16)){pixels[i * 4] = uint8(i * 17); pixels[i * 4 + 1] = uint8(255 - i * 17); pixels[i * 4 + 3] = 255u8} + for (format in [2, 3]){ + var image <- compress_block_texture(pixels, 4, 4, format, false) + var decoded <- decode_block_mip(image, 0) + for (i in range(16)){ + t |> success(abs(int(decoded[i * 4]) - i * 17) <= 20, "red channel survives") + if (format == 3){t |> success(abs(int(decoded[i * 4 + 1]) - (255 - i * 17)) <= 20, "green channel survives")} + } + delete image; delete decoded + } + delete pixels +} diff --git a/skills/daslang/references/everything.md b/skills/daslang/references/everything.md index 235610289e..2535910485 100644 --- a/skills/daslang/references/everything.md +++ b/skills/daslang/references/everything.md @@ -60,6 +60,7 @@ One section per module: what the module is for, then its public symbols grouped - [functional](#functional) - The FUNCTIONAL module implements lazy iterator adapters and higher-order function utilities including `filter`, `map`, `reduce`, `fold`, `scan`, `flatten`, `flat_map`, `enumerate`, `chain`, `pairwise`, `iterate`, `islice`, `cycle`, `repeat`, `sorted`, `sum`, `any`, `all`, `tap`, `for_each`, `find`, `find_index`, and `partition`. - [fuzzer](#fuzzer) - The FUZZER module implements fuzz testing infrastructure for daslang programs. - [generic_return](#generic_return) - The GENERIC_RETURN module provides the `[generic_return]` annotation that allows generic functions to automatically deduce their return type from the body. +- [gltf_processed](#gltf_processed) - Backend-neutral glTF preprocessing that packs and optionally encodes geometry, compresses textures, and persists validated monolithic or split-file assets. - [gltf_types](#gltf_types) - dasGLTF loads `glTF 2.0`_ models — `.glb` (binary), `.gltf` (JSON), external or base64-embedded buffers — into a **backend-neutral scene**, with no new native code (it builds on `json_boost`, `base64`, `fio`, `stbimage` and `math`). - [if_not_null](#if_not_null) - The IF_NOT_NULL module provides a null-safe call macro. - [instance_function](#instance_function) - The INSTANCE_FUNCTION module provides the `[instance_function]` annotation for creating bound method-like functions. @@ -84,6 +85,7 @@ One section per module: what the module is for, then its public symbols grouped - [math_bits](#math_bits) - The MATH_BITS module provides bit-level reinterpretation between integer and floating point representations — `int_bits_to_float`, `uint_bits_to_float`, `float_bits_to_int`, `float_bits_to_uint` (plus the 64-bit `double` forms and 2/3/4-lane vector overloads) — as well as the `cast_to_*` helpers that pack and unpack values through a `float4` payload. - [math_boost](#math_boost) - The MATH_BOOST module adds geometric types (`AABB`, `AABR`, `Ray`), intersection tests (`is_intersecting`), plane helpers (`plane_dot`, `plane_normalize`, `plane_from_point_normal`, `planar_shadow`), color space conversion (`linear_to_SRGB`, `RGBA_TO_UCOLOR`, `UCOLOR_TO_RGBA`), and view/projection matrix construction (`look_at_lh`, `look_at_rh`, `perspective_rh`, `ortho_rh`). - [md_boost](#md_boost) - The MD_BOOST module provides Markdown generation helpers: GitHub-flavored table rendering with aligned columns and per-column alignment, plus small text utilities (bold, cell-separator escaping). +- [meshoptimizer](#meshoptimizer) - Checked array bindings for meshoptimizer 1.2 vertex remapping, vertex-cache and vertex-fetch optimization, and vertex and index buffer codecs. - [module_group](#module_group) - The MODULE_GROUP module calls into every member of a module group. - [network](#network) - The NETWORK module implements networking facilities including HTTP client/server and low-level socket operations. - [only_nttp](#only_nttp) - The ONLY_NTTP module provides the `[only_nttp]` function annotation — a guard for benchmark kernels. @@ -135,6 +137,7 @@ One section per module: what the module is for, then its public symbols grouped - [temp_strings](#temp_strings) - The TEMP_STRINGS module provides temporary string construction that avoids heap allocations. - [templates](#templates) - The TEMPLATES module implements template instantiation utilities for daslang code generation. - [templates_boost](#templates_boost) - The TEMPLATES_BOOST module extends template utilities with high-level macros for common code generation patterns, including template function generation, type-parameterized struct creation, and compile-time code expansion. +- [texture_blocks](#texture_blocks) - Portable BC1, BC3, BC4, and BC5 texture compression with complete mip chains, deterministic cache keys, serialized validation, and RGBA8 decoding. - [toml](#toml) - The TOML module parses `TOML 1.0`_ into the same `JsonValue?` tree shape produced by `daslib/json`, so existing `json_boost` accessors (`v ?? def`, `from_JV`, etc.) work on TOML inputs as-is. - [tty](#tty) - The TTY module answers whether a stream is attached to a real terminal, and how wide that terminal is. - [type_traits](#type_traits) - The TYPE_TRAITS module provides compile-time type introspection and manipulation. @@ -1356,6 +1359,10 @@ Low-level image I/O and resizing bindings for stb_image, stb_image_write, and st - `stbir_resize_uint8_linear` - Resize a uint8 image in linear color space. - `stbir_resize_uint8_srgb` - Resize a uint8 image with sRGB gamma correction. +### Texture block compression + +- `stb_compress_blocks` - Compress one tightly packed RGBA8 image into GPU texture blocks, replicating edge pixels when a dimension is not divisible by four. + ### Animated PNG (APNG) writer - `stbi_apng_begin` - Begin streaming APNG encoding to `filename`. @@ -1363,6 +1370,32 @@ Low-level image I/O and resizing bindings for stb_image, stb_image_write, and st - `stbi_apng_end` - Finalize the APNG file: drain the encoder thread, backpatch the `acTL` frame count, write `IEND`, and free the writer. - `stbi_apng_frame` - Queue one frame on `writer`. +## meshoptimizer + +Checked array bindings for meshoptimizer 1.2 vertex remapping, vertex-cache and vertex-fetch optimization, and vertex and index buffer codecs. The API accepts triangle-list `uint` indices and packed vertex bytes with a nonzero, 4-byte-aligned stride of at most 256 bytes. + + +### Vertex remapping + +- `meshopt_generate_remap` - Build a vertex remap table from triangle-list `indices` and packed `vertices`, returning the number of unique vertices. +- `meshopt_remap_indices` - Apply a vertex remap table to triangle-list `indices` and resize `output` to the index count. +- `meshopt_remap_vertices` - Apply `remap` to packed `vertices` and resize `output` to `count * stride` bytes. + +### Mesh optimization + +- `meshopt_optimize_cache` - Reorder triangle-list `indices` in place to improve post-transform vertex-cache locality while preserving each triangle's orientation. +- `meshopt_optimize_fetch` - Reorder `indices` and packed `vertices` in place for sequential vertex fetch, discard unreferenced vertices, and return the resulting vertex count. + +### Vertex codec + +- `meshopt_decode_vertices` - Decode `count` packed vertex records of `stride` bytes into `output` and return true on success. +- `meshopt_encode_vertices` - Encode packed vertex records into the meshoptimizer vertex codec and resize `output` to the encoded byte count. + +### Index codec + +- `meshopt_decode_indices` - Decode exactly `count` triangle-list indices into `output` and return true on success. +- `meshopt_encode_indices` - Encode triangle-list `indices` with the meshoptimizer index codec and resize `output` to the encoded byte count. + ## raster SIMD-accelerated pixel operations — gather, scatter, copy, channel conversion, and BPC conversion. @@ -2942,6 +2975,72 @@ dasGLTF loads `glTF 2.0`_ models — `.glb` (binary), `.gltf` (JSON), external o - `gltf_read_comp_uint` - Read one component at byte offset `o` as uint (index accessors: u8/u16/u32). - `gltf_type_ncomp` - Number of components for a glTF accessor `type` string (SCALAR/VECn/MATn). +## texture_blocks + +Portable BC1, BC3, BC4, and BC5 texture compression with complete mip chains, deterministic cache keys, serialized validation, and RGBA8 decoding. + + +### Constants + +- `BLOCK_TEXTURE_VERSION` + +### Structures + +- `BlockMip` - One block-compressed mip level and its logical pixel dimensions. +- `BlockTexture` - A complete BC-compressed texture with a deterministic content key and mip chain. + +### Compression and caching + +- `block_texture_key` - Compute the deterministic cache key for tightly packed RGBA8 pixels and their compression settings. +- `cached_block_texture` - Load a valid content-keyed `.das_tex` entry from `directory`, or compress and atomically populate it on a miss. +- `compress_block_texture` - Compress tightly packed RGBA8 pixels into BC1/BC3/BC4/BC5 (`format` 0..3), generating a box-filtered mip chain through 1x1. + +### Validation and decoding + +- `block_texture_valid` - Return true when version, dimensions, format, mip count, and every encoded byte size satisfy the serialized block-texture contract. +- `decode_block_mip` - Decode one BC mip to tightly packed RGBA8 pixels. + +## gltf_processed + +Backend-neutral glTF preprocessing that packs and optionally encodes geometry, compresses textures, and persists validated monolithic or split-file assets. + + +### Constants + +- `DAS_GLTF_VERSION` +- `DAS_GLTF_PROCESSOR_VERSION` +- `VERTEX_UV` +- `VERTEX_TANGENT` +- `VERTEX_SKIN` +- `VERTEX_UV1` +- `VERTEX_COLOR` + +### Structures + +- `ProcessingProfile` - Controls which optional vertex streams and storage encodings survive preprocessing. +- `ProcessedPrimitive` - Portable packed geometry for one primitive, optionally meshoptimizer-encoded. +- `ProcessedMesh` - A named mesh containing portable processed primitives. +- `ProcessedAsset` - Backend-neutral processed glTF data: scene metadata, packed geometry, and BC textures. +- `ProcessedCatalogEntry` - Identifies one processed asset and the key that produced it. +- `ProcessedCatalog` - Versioned list of processed assets for tooling and package manifests. +- `ProcessedManifest` - Split-file manifest for geometry and independently shared texture payloads. + +### Validation and persistence + +- `load_and_process_gltf` - Load an existing processed file by extension, otherwise parse and process a source glTF. +- `load_processed` - Load and validate a `.das_glb` binary or split `.das_gltf` manifest. +- `processed_valid` - Check serialized version, size limits, packed strides and raw byte counts, compressed textures, and scene references. +- `save_processed` - Save a validated asset as one `.das_glb` binary or as a `.das_gltf` manifest plus geometry and texture files. + +### Geometry processing + +- `process_gltf` - Convert a loaded scene into backend-neutral processed geometry and block textures. +- `process_primitive` - Pack one glTF primitive according to `flags`, deduplicate triangle vertices, and optionally optimize ordering and encode storage. +- `processed_indices` - Return decoded uint32 indices for `p`, rejecting codec failures and indices outside the declared vertex range with a panic. +- `processed_stride` - Return the packed vertex stride for the `VERTEX_*` attribute bit mask. +- `processed_vertex_bytes` - Return decoded packed vertex bytes for `p`. +- `unpack_processed_geometry` - Reconstruct ordinary `GltfPrimitive` vertex and index arrays from a processed asset while cloning its scene metadata. + ## spirv_reflect Shader reflection for the dasSpirv SPIR-V backend: an API-neutral description of a shader's descriptor bindings and push-constant ranges, emitted alongside the SPIR-V blob and consumed by the host (e.g. dasVulkan) to auto-build descriptor-set and pipeline layouts. The same per-global classification that emits the SPIR-V fills the reflection, so the host never re-declares set/binding/type by hand. Serialized to an `array` (riding the same module-global-capture rail as the blob) and decoded once at layout-build time; the wire form is versioned so producer/consumer drift fails loudly. diff --git a/src/simulate/bin_serializer.cpp b/src/simulate/bin_serializer.cpp index 3fade32aba..bc1db0210d 100644 --- a/src/simulate/bin_serializer.cpp +++ b/src/simulate/bin_serializer.cpp @@ -115,8 +115,8 @@ namespace das { array_clear(*context, *pa, /*at*/nullptr); // exact reserve first: the final size is known, so the resize never grows - // no pow2 slack on big payloads, and no max_unreserved_size panic - array_reserve(*context, *pa, newSize, getTypeBaseSize(ti), /*at*/nullptr); - array_resize(*context, *pa, newSize, getTypeBaseSize(ti), true, /*at*/nullptr); + array_reserve(*context, *pa, newSize, ti->firstType->size, /*at*/nullptr); + array_resize(*context, *pa, newSize, ti->firstType->size, true, /*at*/nullptr); } else { save(pa->size); } @@ -294,16 +294,23 @@ namespace das { // save ( obj, block<(bytesAt)> ) vec4f _builtin_binary_save ( Context & context, SimNode_CallBase * call, vec4f * args ) { - BinDataSerialize writer(context, &call->debugInfo); - // args - Block * block = cast::to(args[1]); - auto info = call->types[0]; - writer.walk(args[0], info); - writer.close(); - Array arr; - array_mark_locked(arr, writer.bytesAt, writer.bytesWritten); - vec4f arg = cast::from((char *)&arr); - context.invoke(*block, &arg, nullptr, &call->debugInfo); + bool ok = false; + { + BinDataSerialize writer(context, &call->debugInfo); + // args + Block * block = cast::to(args[1]); + auto info = call->types[0]; + writer.walk(args[0], info); + writer.close(); + Array borrowedBytesView; + array_mark_locked(borrowedBytesView, writer.bytesAt, writer.bytesWritten); + vec4f borrowedBytesArgument = cast::from((char *)&borrowedBytesView); + ok = context.runWithCatch([&]() { + context.invoke(*block, &borrowedBytesArgument, nullptr, &call->debugInfo); + }); + context.free(writer.bytesAt, writer.bytesWritten, &call->debugInfo); + } + if (!ok) context.rethrow(); return v_zero(); } diff --git a/tests/dasGLTF/test_gltf_atmosphere.das b/tests/dasGLTF/test_gltf_atmosphere.das new file mode 100644 index 0000000000..714bbdaa51 --- /dev/null +++ b/tests/dasGLTF/test_gltf_atmosphere.das @@ -0,0 +1,17 @@ +options gen2 +require dastest/testing_boost public +require gltf/gltf_atmosphere +require math +[test] +def test_atmosphere_is_shared_and_direction_independent(t : T?){ + let params = float4(.01, 2.0, .15, -1.0) + let a = float3(0.0, -4.0, 0.0); let b = float3(10.0, 2.0, 0.0) + let forward = atmosphere_transmittance(a, b, params, 1.0) + let reverse = atmosphere_transmittance(b, a, params, 1.0) + t |> success(abs(forward - reverse) < .00001, "optical depth is independent of ray direction") + t |> success(forward > 0.0 && forward < 1.0, "fog attenuates without leaving physical bounds") + t |> equal(atmosphere_transmittance(a, b, float4(0.0), 1.0), 1.0, "zero-density atmosphere is neutral") + let below = atmosphere_transmittance(float3(0.0, -4.0, 0.0), float3(10.0, -4.0, 0.0), params, 1.0) + let above = atmosphere_transmittance(float3(0.0, 4.0, 0.0), float3(10.0, 4.0, 0.0), params, 1.0) + t |> success(below < above, "lower layer attenuates more strongly") +} diff --git a/tests/dasGLTF/test_gltf_pbr_common.das b/tests/dasGLTF/test_gltf_pbr_common.das index 301de666b1..8cf6ba1dc4 100644 --- a/tests/dasGLTF/test_gltf_pbr_common.das +++ b/tests/dasGLTF/test_gltf_pbr_common.das @@ -113,3 +113,47 @@ def test_environment_helpers(t : T?) { near(t, gltf_occlusion_factor(0.0, 0.0), 1.0, 1e-6) } } + +[test] +def test_shadow_pcf_subtexel_coverage(t : T?) { + let left = gltf_shadow_pcf_weights(0.0) + let middle = gltf_shadow_pcf_weights(0.5) + let right = gltf_shadow_pcf_weights(1.0) + near(t, left.x + left.y + left.z + left.w, 1.0, 1e-6) + near(t, middle.x + middle.y + middle.z + middle.w, 1.0, 1e-6) + near(t, left.z + left.w, 1.0 / 3.0, 1e-6) + near(t, middle.z + middle.w, 0.5, 1e-6) + near(t, right.z + right.w, 2.0 / 3.0, 1e-6) + near(t, right.z + right.w, left.y + left.z + left.w, 1e-6) + let coverage = float4(0.0, 1.0, 0.0, 1.0) + for (i in range(11)) { + let f = float(i) / 10.0 + let w = gltf_shadow_pcf_weights(f) + let explicit_pcf = (lerp(coverage.x, coverage.y, f) + + lerp(coverage.y, coverage.z, f) + lerp(coverage.z, coverage.w, f)) / 3.0 + near(t, dot(w, coverage), explicit_pcf, 1e-6) + } +} + +[test] +def test_shadow_receiver_plane_pcf(t : T?) { + let slope = float2(0.31, -0.24) + let dx = float3(0.001, 0.0003, dot(slope, float2(0.001, 0.0003))) + let dy = float3(-0.0002, 0.001, dot(slope, float2(-0.0002, 0.001))) + let gradient = gltf_shadow_receiver_gradient(dx, dy) + t |> success(length(gradient - slope) < 0.00001, "recover the geometric depth slope") + let rotated = gltf_shadow_receiver_gradient(dy * 2.0, -dx * 0.5) + t |> success(length(rotated - slope) < 0.00001, "camera-screen basis does not change the receiver plane") + let p = float3(0.4123, 0.571, 0.4) + var old_shadowed = 0 + for (y in range(-2, 3)){for (x in range(-2, 3)){ + let uv = p.xy + float2(float(x), float(y)) / 2048.0 + let plane_depth = p.z + dot(slope, uv - p.xy) + let reference = gltf_shadow_receiver_depth(p, uv, gradient, 0.00001) + t |> success(reference <= plane_depth, "plane must not shadow itself across the filter footprint") + t |> success(reference > plane_depth - 0.01, "a separate occluder still casts a shadow") + if (p.z - 0.00001 > plane_depth){old_shadowed++} + }} + t |> success(old_shadowed > 0, "fixture exposes the old constant-reference acne") + t |> equal(gltf_shadow_receiver_gradient(float3(0.0), float3(0.0)), float2(0.0)) +} diff --git a/tests/dasGLTF/test_gltf_processed.das b/tests/dasGLTF/test_gltf_processed.das new file mode 100644 index 0000000000..d07046be91 --- /dev/null +++ b/tests/dasGLTF/test_gltf_processed.das @@ -0,0 +1,91 @@ +options gen2 +options persistent_heap +require dastest/testing_boost public +require gltf/gltf_processed +require daslib/fio +require math +[test] +def test_processed_files_preserve_geometry_and_animation(t : T?){ + let directory = create_temp_directory_result("gltf_processed") + if (!(directory is value)){t |> failure("create processed asset directory"); return } + let work_dir = unsafe(directory.value) + let root = "{get_das_root()}/tests/dasGLTF/models" + for (relative in ["Box.glb", "BoxInterleaved.glb", "SimpleSkin.gltf"]){ + var raw <- load_gltf("{root}/{relative}") + var profile = ProcessingProfile(texture_cache = "{work_dir}/test-cache") + var cooked <- process_gltf(raw, relative, profile) + t |> success(processed_valid(cooked), "processed asset validates") + t |> success(cooked.processed_vertices <= cooked.source_vertices, "processing does not add vertices") + t |> equal(hash(cooked.scene.nodes), hash(raw.nodes), "named nodes and transforms survive") + t |> equal(hash(cooked.scene.skins), hash(raw.skins), "skin bindings survive") + t |> equal(hash(cooked.scene.animations), hash(raw.animations), "animation data survives") + var unpacked <- unpack_processed_geometry(cooked) + let bounds = gltf_scene_bounds(unpacked) + t |> success(length(bounds._0 - cooked.lo) < 0.0001 && length(bounds._1 - cooked.hi) < 0.0001, "positions/bounds survive packing") + for (ext in [".das_gltf", ".das_glb"]){ + let path = "{work_dir}/roundtrip/{base_name(relative)}{ext}" + t |> success(save_processed(cooked, path, "{work_dir}/roundtrip"), "save custom format") + var loaded <- load_processed(path) + t |> equal(hash(loaded), hash(cooked), "both containers restore the same asset") + delete loaded + } + var strides : array + for (mesh in cooked.meshes){for (p in mesh.primitives){ + t |> success(p.stride < 104, "GPU vertices remain compact") + var vertices <- processed_vertex_bytes(p) + t |> equal(length(vertices), p.vertex_count * p.stride) + delete vertices + }} + delete raw; delete cooked; delete unpacked; delete profile; delete strides + } + let removed = rmdir_rec_result(work_dir) + t |> success(removed is value && unsafe(removed.value), "remove processed asset fixtures") +} + +[test] +def test_translucent_triangle_order(t : T?){ + var raw : GltfScene + raw.materials |> emplace(GltfMaterial(alphaMode = GltfAlphaMode.blend)) + var mesh : GltfMesh; var prim = GltfPrimitive(material = 0) + prim.vertices |> reserve(10) + for (i in range(10)){prim.vertices |> push(GltfVertex(position = float3(float(i), float(i * i % 3), 0.0), normal = float3(0.0, 0.0, 1.0)))} + prim.indices <- [0u, 1u, 2u, 6u, 7u, 8u, 0u, 2u, 3u, 6u, 8u, 9u] + mesh.primitives |> emplace(prim); raw.meshes |> emplace(mesh) + var cooked <- process_gltf(raw, "transparent-fixture", ProcessingProfile(texture_cache = "")) + var decoded <- unpack_processed_geometry(cooked) + let before & = unsafe(raw.meshes[0].primitives[0]); let after & = unsafe(decoded.meshes[0].primitives[0]) + for (tri in range(4)){ + let first = before.vertices[before.indices[tri * 3]].position + var corner = -1 + for (c in range(3)){if (after.vertices[after.indices[tri * 3 + c]].position == first){corner = c}} + t |> success(corner >= 0, "transparent triangle remains in its original position") + if (corner >= 0){for (c in range(3)){t |> equal(after.vertices[after.indices[tri * 3 + (corner + c) % 3]].position, before.vertices[before.indices[tri * 3 + c]].position)}} + } + delete raw; delete cooked; delete decoded +} + +[test] +def test_load_rejects_truncated_encoded_geometry(t : T?){ + var raw : GltfScene; var mesh : GltfMesh; var prim = GltfPrimitive() + prim.vertices <- [ + GltfVertex(position = float3(0.0, 0.0, 0.0), normal = float3(0.0, 0.0, 1.0)), + GltfVertex(position = float3(1.0, 0.0, 0.0), normal = float3(0.0, 0.0, 1.0)), + GltfVertex(position = float3(0.0, 1.0, 0.0), normal = float3(0.0, 0.0, 1.0)) + ] + prim.indices <- [0u, 1u, 2u]; mesh.primitives |> emplace(prim); raw.meshes |> emplace(mesh) + var cooked <- process_gltf(raw, "truncated-fixture", ProcessingProfile(texture_cache = "")) + cooked.meshes[0].primitives[0].vertices |> resize(length(cooked.meshes[0].primitives[0].vertices) - 1) + let file_r = create_temp_file_result("gltf_truncated", ".das_glb") + if (!(file_r is value)){t |> failure("create malformed fixture"); delete raw; delete cooked; return } + let path = unsafe(file_r.value) + t |> success(save_processed(cooked, path), "structural save accepts encoded payload without decoding") + var rejected = false + try { + var loaded <- load_processed(path) + delete loaded + } recover { + rejected = true + } + t |> success(rejected, "load validates encoded payloads before returning") + remove(path); delete raw; delete cooked +} diff --git a/tests/language/bin_serializer.das b/tests/language/bin_serializer.das index 1423eea989..86cfe22118 100644 --- a/tests/language/bin_serializer.das +++ b/tests/language/bin_serializer.das @@ -1,4 +1,5 @@ options gen2 +options persistent_heap require dastest/testing_boost public [safe_when_uninitialized] @@ -36,3 +37,59 @@ def test_bin_serializer(t : T?) { } } } + +//! Keep the embedded fixed array so this fixture exercises differing array allocation strides. +struct BinaryStrideRecord { bytes : array; values : float4[3] } +struct BinaryStrideEnvelope { records : array } +struct BinarySaveBuffer { bytes : array } +[test] +def test_binary_array_element_stride(t : T?){ + var source : BinaryStrideEnvelope; source.records |> resize(3) + for (record, i in source.records, count()){ + record.bytes |> resize(7 + i) + for (value, k in record.bytes, count()){value = uint8(k + i)} + record.values[2] = float4(float(i), 2.0, 3.0, 4.0) + } + binary_save(source) $(data){ + for (_repeat in range(8)){ + var decoded : BinaryStrideEnvelope + binary_load(decoded, data) + t |> equal(hash(decoded), hash(source), "nested arrays deserialize with the element stride") + delete decoded + } + } + delete source +} + +[test] +def test_binary_save_reclaims_temporary_buffer(t : T?){ + var source : BinarySaveBuffer; source.bytes |> resize(64 * 1024) + for (value, i in source.bytes, count()){value = uint8(i)} + binary_save(source) $(data){let _size = length(data)} + let before = heap_bytes_allocated() + for (_repeat in range(16)){ + binary_save(source) $(data){let _size = length(data)} + } + let after = heap_bytes_allocated() + t |> equal(after, before, "binary_save releases each temporary serialization buffer") + delete source +} + +[test] +def test_binary_save_reclaims_buffer_when_callback_panics(t : T?) { + var source : BinarySaveBuffer; source.bytes |> resize(64 * 1024) + try { + binary_save(source) $(data) {let _size = length(data); panic("callback failure")} + } recover {} + let before = heap_bytes_allocated() + var caught = 0 + for (_repeat in range(16)) { + try { + binary_save(source) $(data) {let _size = length(data); panic("callback failure")} + } recover {caught++} + } + let after = heap_bytes_allocated() + t |> equal(caught, 16, "callback exceptions propagate to the caller") + t |> equal(after, before, "callback exceptions release the temporary serialization buffer") + delete source +} diff --git a/utils/daspkg/README.md b/utils/daspkg/README.md index 8475ca9cd3..fc3c6e43a7 100644 --- a/utils/daspkg/README.md +++ b/utils/daspkg/README.md @@ -283,3 +283,10 @@ daslang dastest/dastest.das -- --test utils/daspkg/test_daspkg_git.das - **gh** (GitHub CLI) - optional, only for `introduce`/`withdraw` Run `daspkg doctor` to check your environment. + +For `release wasm`, `release_include_symbols()` enables `--jit-debug --jit-stack` +during cross-compilation and `-g3 --emit-symbol-map` during linking. The deployed +WASM retains generated debug information and function names, with an adjacent +`.html.symbols` map. Prebuilt archives contribute only their existing debug data. +Runtime assertions and heap instrumentation remain explicit `release_emcc_arg` +choices, so requesting symbols alone does not silently enable those checks. diff --git a/utils/daspkg/commands.das b/utils/daspkg/commands.das index b78ec02015..35668a3ca3 100644 --- a/utils/daspkg/commands.das +++ b/utils/daspkg/commands.das @@ -1078,7 +1078,7 @@ def cmd_build_wasm(_root : string; lib_dir_override : string) : int { // Build the wasm64 runtime + the module archives release wasm links against. log("build --wasm: building wasm64 archives...\n") - let targets = "libDaScript_runtime libDasModuleOpenGL libDasModuleGlfw libDasModuleStbImage libDasModuleAudio libDasModuleMinfft libDasModuleLiveHost libDasModuleClipboard" + let targets = "libDaScript_runtime libDasModuleOpenGL libDasModuleGlfw libDasModuleStbImage libDasModuleMeshOptimizer libDasModuleAudio libDasModuleMinfft libDasModuleLiveHost libDasModuleClipboard" var bld_out : string let bld_rc = run_cmd("cmake --build \"{build_dir}\" --target {targets}", bld_out) if (bld_rc != 0) { @@ -3037,7 +3037,7 @@ def private ensure_external_wasm_archives(pkg_dir, wasm_lib_dir : string; var bu // Build one app: cross-compile → discover modules → emcc-link → stage assets. def private release_one_wasm_app(root, out_dir, app_name, main_script : string; // nolint:STYLE037,STYLE038 — flat per-app wasm release steps + their error arms wasm_lib_dir, runtime_archive, shell_path : string; - include_globs, exclude_globs, disabled_modules, app_emcc_args : array) : int { + include_globs, exclude_globs, disabled_modules, app_emcc_args : array; include_symbols : bool) : int { if (!is_safe_pkg_name(app_name)) { to_log(LOG_ERROR, "release wasm: unsafe app name `{app_name}` (letters, digits, `_`, `-` and `.` only; not `.` or `..`)\n") return 1 @@ -3084,7 +3084,8 @@ def private release_one_wasm_app(root, out_dir, app_name, main_script : string; write(w, " --disable-module {m}") } } - let xc_cmd = "\"{daslang}\" -exe -output \"{obj_path}\" --list-shared-modules \"{deps_file}\" -project-root \"{root}\" {disable_args} \"{main_path}\" -- --jit-target=wasm64-unknown-emscripten --jit-emit-object --jit-check-abi --jit-threads --jit-runtime-lib=\"{runtime_archive}\"" + let debug_args = include_symbols ? " --jit-debug --jit-stack" : "" + let xc_cmd = "\"{daslang}\" -exe -output \"{obj_path}\" --list-shared-modules \"{deps_file}\" -project-root \"{root}\" {disable_args} \"{main_path}\" -- --jit-target=wasm64-unknown-emscripten --jit-emit-object --jit-check-abi --jit-threads{debug_args} --jit-runtime-lib=\"{runtime_archive}\"" var xc_out : string let xc_rc = run_cmd(xc_cmd, xc_out) if (xc_rc != 0 || !fexist(obj_path)) { @@ -3190,6 +3191,7 @@ def private release_one_wasm_app(root, out_dir, app_name, main_script : string; // EXPORTED_RUNTIME_METHODS=FS,ENV: a custom shell stages files into MEMFS and sets the // program's environment knobs in Module.preRun (the C environment is built from Module.ENV at startup). w |> write(" -sMEMORY64=1 -sFULL_ES3 -sMAX_WEBGL_VERSION=2 -sGL_ENABLE_GET_PROC_ADDRESS=1 -sALLOW_MEMORY_GROWTH=1 -sSTACK_SIZE=4MB -fwasm-exceptions -sWASM_LEGACY_EXCEPTIONS=0 -sEXIT_RUNTIME=0 -sFORCE_FILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,ENV -pthread -sPTHREAD_POOL_SIZE=16 -sAUDIO_WORKLET=1 -sWASM_WORKERS=1 -Wl,--wrap=emscripten_futex_wait") + if (include_symbols) {w |> write(" -g3 --emit-symbol-map")} for (a in emcc_args) { w |> write(" {a}") } @@ -3281,5 +3283,5 @@ def cmd_release_wasm(root : string; out_dir : string; wasm_lib_dir_override : st } mkdir_rec(out_dir) - return release_one_wasm_app(root, out_dir, bundle_name, main_script, wasm_lib_dir, runtime_archive, shell_path, spec.include_globs, spec.exclude_globs, spec.wasm_disabled_modules, spec.emcc_args) + return release_one_wasm_app(root, out_dir, bundle_name, main_script, wasm_lib_dir, runtime_archive, shell_path, spec.include_globs, spec.exclude_globs, spec.wasm_disabled_modules, spec.emcc_args, spec.include_symbols) }