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) }