diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index acc9dbab0c..414fbcc412 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,6 +14,7 @@ permissions: env: CMAKE_BUILD_PARALLEL_LEVEL: 4 CTEST_PARALLEL_LEVEL: 4 + NAGA_VERSION: "30.0.0" jobs: @@ -239,6 +240,24 @@ jobs: if: matrix.clang_format == 'ON' run: find source \( -name *.h -o -name *.cpp -o -name *.mm -o -name *.inl \) ! -path "*/External/*" ! -path "*/NanoGUI/*" | xargs clang-format -i --verbose + - name: Install naga + if: matrix.python != 'None' + run: cargo install naga-cli --locked --version ${{ env.NAGA_VERSION }} + + - name: Generate WGSL library + if: matrix.python != 'None' + run: | + # Cleanup deps (tree-sitter) are optional and have no wheel on some interpreters (e.g. 3.9); + # install best-effort so their absence doesn't fail the build. Generation degrades to + # verbose-but-valid WGSL, and mxgenwgsl.py stays fatal so real failures still surface. + python -m pip install -r source/MaterialXGenWgsl/tools/requirements-transpile.txt \ + || echo "WGSL cleanup deps unavailable; generating verbose WGSL" + python source/MaterialXGenWgsl/tools/mxgenwgsl.py --libraries libraries --out libraries + + - name: Test WGSL cleanup + if: matrix.python != 'None' + run: python source/MaterialXGenWgsl/tools/test_mxwgslcleanup.py + - name: CMake Generate shell: bash run: | @@ -247,6 +266,7 @@ jobs: -DMATERIALX_BUILD_PYTHON=ON \ -DMATERIALX_BUILD_TESTS=ON \ -DMATERIALX_TEST_RENDER=${{ matrix.extended_build_osl == 'ON' && 'ON' || 'OFF' }} \ + -DMATERIALX_BUILD_GEN_WGSL=${{ matrix.python != 'None' && 'ON' || 'OFF' }} \ -DMATERIALX_WARNINGS_AS_ERRORS=ON \ ${{ env.IS_EXTENDED_BUILD == 'true' && matrix.extended_cmake_config || '' }} \ ${{ matrix.cmake_config }} \ @@ -290,7 +310,6 @@ jobs: python python/Scripts/generateshader.py resources/Materials/Examples --target glsl --validator C:/vcpkg/installed/x64-windows-release/tools/glslang/glslangValidator.exe python python/Scripts/generateshader.py resources/Materials/Examples/StandardSurface --target essl --validator C:/vcpkg/installed/x64-windows-release/tools/glslang/glslangValidator.exe python python/Scripts/generateshader.py resources/Materials/Examples/StandardSurface --target vulkan --validator C:/vcpkg/installed/x64-windows-release/tools/glslang/glslangValidator.exe - python python/Scripts/generateshader.py resources/Materials/Examples/StandardSurface --target wgsl --validator "C:/vcpkg/installed/x64-windows-release/tools/glslang/glslangValidator.exe --target-env vulkan1.3 --quiet" - name: Shader Validation Tests (MacOS) if: matrix.test_shaders == 'ON' && runner.os == 'macOS' @@ -459,8 +478,21 @@ jobs: with: node-version: '22.16.0' + - name: Install naga + run: cargo install naga-cli --locked --version ${{ env.NAGA_VERSION }} + + - name: Install Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: 3.13 + + - name: Generate WGSL library + run: | + python -m pip install -r source/MaterialXGenWgsl/tools/requirements-transpile.txt + python source/MaterialXGenWgsl/tools/mxgenwgsl.py --libraries libraries --out libraries + - name: JavaScript CMake Generate - run: cmake -S . -B javascript/build -DMATERIALX_BUILD_JS=ON -DMATERIALX_EMSDK_PATH=${{ env.EMSDK }} + run: cmake -S . -B javascript/build -DMATERIALX_BUILD_JS=ON -DMATERIALX_BUILD_GEN_WGSL=ON -DMATERIALX_EMSDK_PATH=${{ env.EMSDK }} - name: JavaScript CMake Build run: cmake --build javascript/build --target install --config Release @@ -511,6 +543,14 @@ jobs: with: python-version: 3.11 + - name: Install naga + run: cargo install naga-cli --locked --version ${{ env.NAGA_VERSION }} + + - name: Generate WGSL library + run: | + python -m pip install -r source/MaterialXGenWgsl/tools/requirements-transpile.txt + python source/MaterialXGenWgsl/tools/mxgenwgsl.py --libraries libraries --out libraries + - name: Build SDist id: generate run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd4b23323c..350f526195 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,7 @@ jobs: runs-on: ubuntu-latest env: RELEASE_TAG: ${{ github.ref_name }} + NAGA_VERSION: "30.0.0" permissions: contents: write id-token: write @@ -28,6 +29,19 @@ jobs: - name: Create Archive Name run: echo "MATERIALX_ARCHIVE=MaterialX-${RELEASE_TAG//v}" >> $GITHUB_ENV + - name: Install Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: 3.11 + + - name: Install naga + run: cargo install naga-cli --locked --version ${NAGA_VERSION} + + - name: Generate WGSL library + run: | + python -m pip install -r source/MaterialXGenWgsl/tools/requirements-transpile.txt + python source/MaterialXGenWgsl/tools/mxgenwgsl.py --libraries libraries --out libraries + - name: Generate Archives run: | git archive --prefix ${MATERIALX_ARCHIVE}/ --output ${MATERIALX_ARCHIVE}.zip ${RELEASE_TAG} diff --git a/CMakeLists.txt b/CMakeLists.txt index acb1afbc67..599d1fdd35 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,8 @@ option(MATERIALX_BUILD_GEN_OSL "Build the OSL shader generator back-ends." ON) option(MATERIALX_BUILD_GEN_MDL "Build the MDL shader generator back-end." ON) option(MATERIALX_BUILD_GEN_MSL "Build the MSL shader generator back-end." ON) option(MATERIALX_BUILD_GEN_SLANG "Build the Slang shader generator back-end." ON) +option(MATERIALX_BUILD_GEN_WGSL "Build the WGSL (WebGPU) shader generator back-end." OFF) +option(MATERIALX_GENERATE_WGSL_LIBRARY "Regenerate the genwgsl shader-node library from genglsl at build time (requires Python and naga); surfaces transpile/validation failures as build errors." OFF) option(MATERIALX_BUILD_RENDER "Build the MaterialX Render modules." ON) option(MATERIALX_BUILD_RENDER_PLATFORMS "Build platform-specific render modules for each shader generator." ON) option(MATERIALX_BUILD_OIIO "Build OpenImageIO support for MaterialXRender." OFF) @@ -126,6 +128,8 @@ set(MATERIALX_PYTHON_EXECUTABLE "" CACHE FILEPATH "Python executable to be used in building the MaterialX Python package (e.g. 'C:/Python39/python.exe').") set(MATERIALX_PYTHON_PYBIND11_DIR "" CACHE PATH "Path to a folder containing the PyBind11 source to be used in building MaterialX Python.") +set(MATERIALX_CARGO_PATH "" CACHE PATH + "Path to a Rust cargo installation (the CARGO_HOME directory containing bin/cargo, e.g. '~/.cargo'); used to obtain the naga CLI when MATERIALX_GENERATE_WGSL_LIBRARY is on and naga is not already found.") option(MATERIALX_PYTHON_FORCE_REPLACE_DOCS "Force replace existing docstrings when generating Python binding documentation from Doxygen." OFF) # Settings to define installation layout @@ -176,6 +180,9 @@ set(MATERIALX_LIBNAME_SUFFIX "" CACHE STRING "Specify a suffix to all libraries mark_as_advanced(MATERIALX_BUILD_DOCS) mark_as_advanced(MATERIALX_BUILD_GEN_GLSL) mark_as_advanced(MATERIALX_BUILD_GEN_SLANG) +mark_as_advanced(MATERIALX_BUILD_GEN_WGSL) +mark_as_advanced(MATERIALX_GENERATE_WGSL_LIBRARY) +mark_as_advanced(MATERIALX_CARGO_PATH) mark_as_advanced(MATERIALX_BUILD_GEN_OSL) mark_as_advanced(MATERIALX_BUILD_GEN_MDL) mark_as_advanced(MATERIALX_BUILD_GEN_MSL) @@ -527,8 +534,8 @@ endif() # Add shader generation subdirectories add_subdirectory(source/MaterialXGenShader) -if(MATERIALX_BUILD_GEN_GLSL OR MATERIALX_BUILD_GEN_OSL OR MATERIALX_BUILD_GEN_MDL OR MATERIALX_BUILD_GEN_MSL OR MATERIALX_BUILD_GEN_SLANG) - if(MATERIALX_BUILD_GEN_GLSL OR MATERIALX_BUILD_GEN_MSL OR MATERIALX_BUILD_GEN_SLANG) +if(MATERIALX_BUILD_GEN_GLSL OR MATERIALX_BUILD_GEN_OSL OR MATERIALX_BUILD_GEN_MDL OR MATERIALX_BUILD_GEN_MSL OR MATERIALX_BUILD_GEN_SLANG OR MATERIALX_BUILD_GEN_WGSL) + if(MATERIALX_BUILD_GEN_GLSL OR MATERIALX_BUILD_GEN_MSL OR MATERIALX_BUILD_GEN_SLANG OR MATERIALX_BUILD_GEN_WGSL) add_subdirectory(source/MaterialXGenHw) endif() if (MATERIALX_BUILD_GEN_GLSL) @@ -539,6 +546,10 @@ if(MATERIALX_BUILD_GEN_GLSL OR MATERIALX_BUILD_GEN_OSL OR MATERIALX_BUILD_GEN_MD add_definitions(-DMATERIALX_BUILD_GEN_SLANG) add_subdirectory(source/MaterialXGenSlang) endif() + if (MATERIALX_BUILD_GEN_WGSL) + add_definitions(-DMATERIALX_BUILD_GEN_WGSL) + add_subdirectory(source/MaterialXGenWgsl) + endif() if (MATERIALX_BUILD_GEN_OSL) add_definitions(-DMATERIALX_BUILD_GEN_OSL) add_subdirectory(source/MaterialXGenOsl) diff --git a/documents/DeveloperGuide/WGSLShaderGeneration.md b/documents/DeveloperGuide/WGSLShaderGeneration.md new file mode 100644 index 0000000000..2e61361d7d --- /dev/null +++ b/documents/DeveloperGuide/WGSLShaderGeneration.md @@ -0,0 +1,257 @@ +# WGSL Shader Generation + +MaterialX includes a native [WGSL](https://www.w3.org/TR/WGSL/) (WebGPU Shading Language) shader generator back-end, registered under the `genwgsl` target. `WgslShaderGenerator` derives from `HwShaderGenerator` and emits standalone WGSL vertex and fragment shaders, similar in structure to the MSL and Slang back-ends. + +This guide covers the tooling, CMake configuration, and local workflows for working with the WGSL target. For general shader generation concepts, see [Shader Generation](ShaderGeneration.md). For implementation details of the transpiler itself, see [`source/MaterialXGenWgsl/tools/README.md`](../../source/MaterialXGenWgsl/tools/README.md). + +## Overview + +The `genwgsl` uses a **hybrid node library**: + +| Library content | Source | Committed to git? | +| --- | --- | --- | +| Most node `.wgsl` files | Transpiled from `genglsl` by `mxgenwgsl.py` | No — derived artifact | +| Core `lib/` math and closure helpers | Transpiled from `genglsl/lib/` by `mxgenwgsl.py` | No — derived artifact | +| Texture, image, and light nodes | Hand-maintained | Yes | +| `mx_chiang_hair_bsdf` (`EXPECTED_FALLBACK`) | Hand-maintained | Yes | + +The GLSL node and lib libraries (`genglsl`, `genglsl/lib/`) are the **single source of truth** for generated WGSL. CI runs the transpiler on every job that generates WGSL to prevent drift between GLSL and WGSL libraries. + +The library lives under `libraries/{stdlib,pbrlib,lights}/genwgsl/`, with the target defined in `libraries/targets/genwgsl.mtlx`. + +## Prerequisites + +| Requirement | Notes | +| --- | --- | +| **Python 3.9+** | Required to run the transpiler | +| **naga-cli** (v29+) | `cargo install naga-cli`, or set the `NAGA` environment variable to the binary path | +| **Rust cargo** (optional) | Only needed if naga is not already installed; CMake can build naga into the build tree | +| **Emscripten 4.0.8** | Required only for JavaScript / WebGPU viewer testing | + +## CMake Options + +| Option | Default | Description | +| --- | --- | --- | +| `MATERIALX_BUILD_GEN_WGSL` | `OFF` | Build the `MaterialXGenWgsl` library and enable the `genwgsl` shader target | +| `MATERIALX_GENERATE_WGSL_LIBRARY` | `OFF` | Add a `MaterialXGenWgslLibrary` build target that re-transpiles into the build tree as a validation aid | +| `MATERIALX_NAGA_EXECUTABLE` | (auto-detect) | Path to the `naga` CLI | +| `MATERIALX_CARGO_PATH` | (auto-detect) | Path to a Rust cargo home (used to install naga if not found) | + +`MATERIALX_BUILD_GEN_WGSL` is **off by default** so ordinary builds do not require Python or naga. Enable it when working on the WGSL target. + +`MATERIALX_GENERATE_WGSL_LIBRARY` is independent of `MATERIALX_BUILD_GEN_WGSL` and writes generated files to the build tree only (not the source tree). + +## Tooling + +### `mxgenwgsl.py` + +The transpiler at `source/MaterialXGenWgsl/tools/mxgenwgsl.py` converts `genglsl` node fragments into `genwgsl` equivalents using [naga](https://github.com/gfx-rs/wgpu/tree/trunk/naga). + +**NOTE:*** It is **not** a general-purpose GLSL-to-WGSL converter. It is scoped to MaterialX shader-node fragments. + +**Regenerate the full library in place:** + +```sh +python source/MaterialXGenWgsl/tools/mxgenwgsl.py --libraries libraries --out libraries +``` + +**Regenerate specific nodes only:** + +```sh +python source/MaterialXGenWgsl/tools/mxgenwgsl.py --libraries libraries --out libraries --only mx_noise3d_float mx_sheen_bsdf +``` + +A non-zero exit code means an *unexpected* node failed (a regression). Known fallback nodes listed in `EXPECTED_FALLBACK` are tolerated. + +### What the transpiler does + +`mxgenwgsl.py` is **not** a general-purpose GLSL-to-WGSL converter. It transpiles MaterialX **shader-node fragments** — one function per file, referenced by `file=` implementations in `stdlib`, `pbrlib`, and `lights`. naga performs the actual translation; the script wraps it with a pre-processor and post-processor so incomplete node fragments become valid input and the output matches genwgsl library conventions. + +**Pipeline (per node function):** + +1. **Lib helpers** — transpile `genglsl/lib/*.glsl` first (topological include order, `LIB_PREAMBLE`, overload renaming via `mangle()`) +2. **Pre-process** — wrap the node fragment in a complete GLSL shader naga can parse +3. **Transpile** — `naga --input-kind glsl --shader-stage frag` +4. **Post-process** — clean up naga output and remap helper calls to genwgsl names + +#### Input: an incomplete node fragment + +A typical genglsl node is not valid standalone GLSL. It has `#include`s, no `main`, and sometimes MaterialX `$`-tokens: + +```glsl +#include "lib/mx_noise.glsl" + +void mx_noise3d_float(float amplitude, float pivot, vec3 position, out float result) +{ + float value = mx_perlin_noise_float(position); + result = value * amplitude + pivot; +} +``` + +The tool cannot feed this directly to naga. Instead it synthesizes a complete translation unit: + +- Shared `#define` / `const` / `struct` context from included libs (prototypes only, not full bodies) +- Closure structs (`BSDF`, `surfaceshader`, etc.) +- `$`-token placeholders swapped for legal identifiers (e.g. `$texSamplerSampler2D` → `MTLXTOK_texSamplerSampler2D`, restored after transpile) +- The one node function body being transpiled +- A dummy `main()` entry point (required by naga's GLSL frontend) + +#### Post-process: genwgsl conventions + +naga's output is correct but verbose (SSA-style parameter shadows, `vec3` syntax, etc.). The post-processor: + +- Collapses single-use temporaries and parameter-copy shadows +- Normalizes types (`vec3` → `vec3f`, `2f` → `2.0`) +- Remaps overloaded GLSL helper calls via `mangle()` to type-suffixed genwgsl names +- Re-attaches GLSL comments naga discarded (doc/leading blocks verbatim; inline body comments best-effort) + +WGSL has no function overloading, so the genwgsl `lib/` gives each GLSL overload a distinct name. For the noise example above, `mx_perlin_noise_float(position)` with a `vec3` argument is rewritten to `mx_perlin_noise_float_3d(position)`. + +Similarly, GLSL `mx_square` overloads map to `mx_square_f32`, `mx_square_vec2`, or `mx_square_vec3` depending on the argument type. + +#### What it handles + +| Category | Example | Notes | +| --- | --- | --- | +| Standalone math / utility nodes | `mx_noise3d_float`, `mx_mix_surfaceshader` | `#include` lib helpers; calls remapped via `mangle()` | +| PBR nodes with standard lib signatures | Most BSDF combiners, EDF nodes | Generated when all helper calls resolve | +| Closure / `inout` parameters | `inout BSDF bsdf` | Closure preamble supplies `BSDF` struct; `inout` becomes `ptr` | +| Cross-node helper calls | One node calling another node's function | Prototypes collected from all genglsl files | + +**Illustrative output** (post-processed fragment for `mx_noise3d_float`): + +```wgsl +#include "lib/mx_noise.wgsl" + +fn mx_noise3d_float(amplitude: f32, pivot: f32, position: vec3f, result: ptr) { + let value = mx_perlin_noise_float_3d(position); + (*result) = value * amplitude + pivot; +} +``` + +Generated files carry a `// Generated from … do not edit` banner. + +#### What it does not handle + +| Category | Example | Reason | +| --- | --- | --- | +| Texture / image nodes | `mx_image_color3` | naga's GLSL frontend has no sampler support — auto-skipped by filename pattern (`image`, `hextiled`) | +| Light shaders | `mx_point_light` | Use dynamically generated `LightData` — auto-skipped (`_light$` pattern) | +| Chiang hair BSDF | `mx_chiang_hair_bsdf` | naga limitation on hair scattering helpers — listed in `EXPECTED_FALLBACK`, kept hand-written | +| Unmapped overloads | An overload `mangle()` resolves to `None` (e.g. an adapted-signature BSDF helper) | Node stays hand-written; logged as unsupported | + +**Specular environment IBL:** `WgslShaderGenerator` supports FIS, prefilter, and none methods +(`mx_environment_fis.wgsl`, `mx_environment_prefilter.wgsl`, `mx_environment_none.wgsl`). The +prefilter environment bake pass (`hwWriteEnvPrefilter` / `mx_generate_prefilter_env`) remains +GLSL-only today. + +**Texture node** (auto-skipped — uses samplers naga cannot parse): + +```glsl +#include "lib/$fileTransformUv" + +void mx_image_color3($texSamplerSignature, int layer, vec3 defaultval, vec2 texcoord, ...) +{ + vec2 uv = mx_transform_uv(texcoord, uv_scale, uv_offset); + result = texture($texSamplerSampler2D, uv).rgb; +} +``` + +The result is a **reduced library**: most nodes and all 22 `lib/` helpers are generated from +genglsl; texture, light, and `mx_chiang_hair_bsdf` remain hand-written. The tool exits non-zero +only when a file *outside* `EXPECTED_FALLBACK` fails unexpectedly (a regression). If a fallback node +starts transpiling cleanly, the tool prints a warning so it can be removed from `EXPECTED_FALLBACK`. + +For full transpiler internals see [`source/MaterialXGenWgsl/tools/README.md`](../../source/MaterialXGenWgsl/tools/README.md). + +### CI + +GitHub Actions runs `mxgenwgsl.py` on Python-enabled build jobs, the JavaScript job, the Python sdist job, and release archives. A GLSL change that breaks WGSL generation will fail CI even without a local naga install. + +## Local Developer Workflows + +### After modifying a GLSL node + +1. Regenerate the WGSL library: + ```sh + python source/MaterialXGenWgsl/tools/mxgenwgsl.py --libraries libraries --out libraries + ``` +2. Configure with `-DMATERIALX_BUILD_GEN_WGSL=ON` and rebuild. +3. Run the `[genwgsl]` unit tests: + ```sh + ctest -R genwgsl + ``` + +### C++ shader-generation testing + +This is the fastest path for validating WGSL output without Emscripten: + +```sh +python source/MaterialXGenWgsl/tools/mxgenwgsl.py --libraries libraries --out libraries + +cmake -S . -B build -DMATERIALX_BUILD_GEN_WGSL=ON +cmake --build build --config Release +ctest -R genwgsl --test-dir build +``` + +The `[genwgsl]` tests in `source/MaterialXTest/MaterialXGenWgsl/GenWgsl.cpp` cover syntax, target registration, and shader generation from example materials. + +### JavaScript / WebGPU viewer testing + +For end-to-end testing in the browser (Three.js WebGPU renderer, TSL bridge), build with both JavaScript and WGSL enabled: + +```sh +python source/MaterialXGenWgsl/tools/mxgenwgsl.py --libraries libraries --out libraries + +cmake -S . -B javascript/build \ + -DMATERIALX_BUILD_JS=ON \ + -DMATERIALX_BUILD_GEN_WGSL=ON \ + -DMATERIALX_EMSDK_PATH= \ + -G Ninja +cmake --build javascript/build --target install --config Release +``` + +On Windows, `javascript/build_javascript_win.bat` automates this flow: Emscripten build, npm install, Playwright tests, and a local dev server at `http://localhost:8080`. + +The viewer produces two webpack bundles from the same source: + +| Page | Backend | Renderer | +| --- | --- | --- | +| `index.html` | WebGL | `THREE.WebGLRenderer` + ESSL (`RawShaderMaterial`) | +| `index-webgpu.html` | WebGPU | `WebGPURenderer` + WGSL (via TSL / `NodeMaterial`) | + +Separate bundles are used because the WebGL and WebGPU Three.js entry points are incompatible. A toggle link switches between the two pages. + +To build the viewer bundle after WASM is ready: + +```sh +cd javascript/MaterialXView +npm install +npm run build +npm run start # dev server at http://localhost:8080 +``` + +### Build-time validation (optional) + +To re-transpile into the build tree on every build (without modifying the source tree): + +```sh +cmake -S . -B build \ + -DMATERIALX_BUILD_GEN_WGSL=ON \ + -DMATERIALX_GENERATE_WGSL_LIBRARY=ON +cmake --build build +``` + +This adds the `MaterialXGenWgslLibrary` target, which surfaces transpile and naga validation failures as build errors. + +## Release Artifacts + +Generated WGSL node and `lib/` files are not committed to git, but they are included in release archives. The release workflow runs `mxgenwgsl.py` before packaging, so `libraries/*/genwgsl/**/*.wgsl` files ship in the archive. + +## Related Documentation + +- [WGSL Transpiler Fixes](WGSLTranspilerFixes.md) — generation policy and the naga-reconciliation fixes for full-library generation +- [Shader Generation](ShaderGeneration.md) — general shader generation framework +- [`source/MaterialXGenWgsl/README.md`](../../source/MaterialXGenWgsl/README.md) — back-end layout and design +- [`source/MaterialXGenWgsl/tools/README.md`](../../source/MaterialXGenWgsl/tools/README.md) — transpiler internals, `mangle()` overload naming, and `EXPECTED_FALLBACK` +- [`javascript/README.md`](../../javascript/README.md) — JavaScript bindings and viewer setup diff --git a/documents/DeveloperGuide/WGSLTranspilerFixes.md b/documents/DeveloperGuide/WGSLTranspilerFixes.md new file mode 100644 index 0000000000..6e3f27179b --- /dev/null +++ b/documents/DeveloperGuide/WGSLTranspilerFixes.md @@ -0,0 +1,87 @@ +# WGSL Transpiler: Full-Library Generation Fixes + +This note summarizes the changes that make `mxgenwgsl.py` generate the **entire** `genwgsl` node +and lib library from `genglsl` (previously many files were hand-written). It covers (1) the +generation policy — what is generated vs. kept hand-written — and (2) the transpiler correctness +fixes needed to reconcile naga's WGSL output with the genwgsl library conventions. + +For the general workflow and tooling see [WGSLShaderGeneration.md](WGSLShaderGeneration.md) and +[`source/MaterialXGenWgsl/tools/README.md`](../../source/MaterialXGenWgsl/tools/README.md). + +## Generation policy + +| Scope | Handling | Mechanism | +| --- | --- | --- | +| `pbrlib/genwgsl/` nodes + most `pbrlib/genwgsl/lib` helpers | **Generated** | transpiled from `genglsl` | +| `stdlib/genwgsl/lib` helpers | **Hand-written** (project choice) | `HANDWRITTEN_LIB_DIRS` | +| Environment / shadow / bake lib helpers | **Hand-written** | `LIB_KEEP_HANDWRITTEN` | +| Image / hextile / light nodes | **Hand-written** (auto-skipped) | `SKIP_PATTERNS` | +| `mx_chiang_hair_bsdf` | **Hand-written** | `EXPECTED_FALLBACK` | + +**Why some lib helpers stay hand-written.** The environment (`mx_environment_fis/none/prefilter`), +shadow (`mx_shadow`, `mx_shadow_platform`), and bake (`mx_generate_albedo_table`, +`mx_generate_prefilter_env`) helpers sample textures through generator-substituted `$`-token +**texture + sampler** bindings. naga's GLSL frontend cannot parse sampler types, so the transpiler +can only stub those calls — the stubs would leak into the output as unresolved references. These +files are therefore listed in `LIB_KEEP_HANDWRITTEN` and maintained by hand (the same limitation +already skips image/light *nodes*). `mx_environment_prefilter.wgsl` and +`mx_generate_prefilter_env.wgsl` were hand-authored to match the `mx_environment_fis.wgsl` +conventions. + +The four BSDF nodes `mx_conductor/dielectric/generalized_schlick/subsurface_bsdf` now transpile +cleanly and are generated; only `mx_chiang_hair_bsdf` remains an `EXPECTED_FALLBACK`. + +## Transpiler correctness fixes + +naga produces valid but SSA-style WGSL and deterministically rewrites identifiers. The following +post-processing was added/fixed so the generated output matches the genwgsl library and the +`WgslShaderGenerator` runtime template. All changes are in +`source/MaterialXGenWgsl/tools/mxgenwgsl.py`. + +| Area | Symptom before fix | Fix | +| --- | --- | --- | +| **Struct parsing** (`transpile_glsl_structs`) | GLSL comments inside a struct (e.g. `// Fresnel model`) were parsed as fields (`Fresnel: //,`) and array fields (`vec2 c[3]`) became `c[3]: vec2f` | Strip line/block comments; rewrite array fields to `array`; skip types provided centrally by `wgsl_closure_preamble()` (`LIB_PREAMBLE_STRUCTS`) so `FresnelData`/`ClosureData` aren't redeclared | +| **`surfaceshader`** | Redeclared — emitted both by `WgslShaderGenerator::emitTypeDefinitions` and by the lib preamble | `surfaceshader` is parsed from `GlslSyntax.cpp` for node context only; the WGSL lib preamble omits it (the generator owns it; the `material = surfaceshader` alias still resolves) | +| **File-scope consts** (`transpile_glsl_consts`) | Function-local `const int SAMPLE_COUNT` was hoisted to module scope → duplicate / redeclared consts across microfacet libs | Blank out function bodies before matching, so only true file-scope consts are emitted | +| **naga identifier reconciliation** (`denaga_name`) | naga suffixes references to a digit-ending const (`FUJII_CONSTANT_1_`), a name it sees twice (`FRESNEL_MODEL_SCHLICK_1`), the digit-ending fields `F0`/`F82`/`F90` (`F0_`), and prototype names — leaving them mismatched | One Namer-grounded helper (`proc/namer.rs`) maps `NAME_` / `NAME_` references back to the clean declaration name; used by `resolve_const_refs`, `apply_field_renames` (digit fields from the derived preamble), and the prototype-underscore strip. Keyed only on known names, so numbered locals are untouched | +| **`FresnelData` fields** | GLSL used `tf_*` while the hand-ported WGSL lib used `thinfilm_*`, requiring a transpiler rename | Renamed `tf_*` → `thinfilm_*` in `mx_microfacet_specular.glsl` so GLSL and emitted WGSL share one field layout; removed `LIB_FIELD_RENAMES` | +| **Overloaded helpers** (`mangle`) | WGSL has no overloading, so `mx_bilerp`, `mx_hash_int`, `mx_cell_noise_vec3`, `mx_fresnel_schlick`, etc. collided | `mangle(name, types, overloaded)` derives a type-suffixed name from a per-family `SUFFIX_SCHEME` (default: first parameter type) plus a small `EXCEPTIONS` table for irregular/semantic names; the overload *keys* come from scanning genglsl, not a hand-listed table | +| **Overload resolution** (`extract_transpiled_fn`, `remap_calls_by_naga_sig`) | For overloaded siblings (`mx_ggx_dir_albedo` vec3/scalar/FresnelData) the wrong body was extracted and calls resolved to the wrong overload | Extract by parameter **signature** when a name is ambiguous; remap calls using the **actual per-module signatures** naga assigned, instead of assuming a sorted index order | +| **`out`/`inout` params** (`naga_proto_params`) | Stripping `out`/`inout` from prototypes made callers pass a dereferenced value (`mx_normalmap_vector2(..., (*result))`) instead of the pointer | Keep `out`/`inout` in prototypes (only strip the neutral `const`/`in`) so naga passes `ptr` | +| **Comment preservation** (`parse_functions`, `inject_inline_comments`) | naga discards all GLSL comments, so doc comments and references were lost | Re-attach from genglsl: function `lead` (doc) blocks and file-level top comments verbatim; inline `//` body comments best-effort via stable-token anchors (whole-line insertion, unmatched dropped, so output is never corrupted). Struct/const comments stay stripped | +| **Scan-driven validation** (`validate_overload_coverage` / `_token_coverage` / `_lib_names`) | A new genglsl overload or `$`-token could silently mis-emit a name or fail naga with an obscure error | Preflight hard-errors when an overload has no `mangle()` mapping, a `$`-token in a transpiled lib source is undeclared in `HwConstants.cpp` and unstubbed in `NAGA_LIB_STUBS`, or a `mangle()` name is absent from the real genwgsl lib | +| **Derived closure preambles** (`wgsl_closure_preamble` / `glsl_closure_preamble`) | `CLOSURE_PREAMBLE`, `LIB_STRUCT_PREAMBLE`, and `LIB_TOKEN_FIXUPS` duplicated C++/GLSL sources | Emit closure structs from `genglsl/lib` + `GlslSyntax.cpp`; validate token names from `HwConstants.cpp`; keep only naga-specific stub values in `NAGA_LIB_STUBS` | + +### The recurring theme + +naga is deterministic but rewrites identifiers to keep its output valid: its `Namer` +(`proc/namer.rs`) appends `_` to any name ending in a digit or a keyword/builtin, `_N` to colliding +names, and renders `out`/`inout` params as `ptr`. Wherever the transpiler re-emits a +symbol itself (a hand-written struct/const, or a prototype), the generated references must be +reconciled back to that symbol — now via the single Namer-grounded `denaga_name()` helper. The fixes +above are all instances of that reconciliation. + +## Build / repo workflow + +Generated `.wgsl` files carry a `// Generated from … mxgenwgsl.py` banner and are **not +committed** — they are regenerated from `genglsl` (the single source of truth) in CI and locally. +Because `MATERIALX_BUILD_GEN_WGSL` now defaults **OFF**, a clean local build of the web viewer is: + +```sh +# 1. regenerate the WGSL library (genglsl -> genwgsl) +python source/MaterialXGenWgsl/tools/mxgenwgsl.py --libraries libraries --out libraries + +# 2. configure with the WGSL backend enabled +cmake -S . -B javascript/build \ + -DMATERIALX_BUILD_JS=ON -DMATERIALX_BUILD_GEN_WGSL=ON \ + -DMATERIALX_EMSDK_PATH= -G Ninja + +# 3. build the WASM modules (libraries are embedded into JsMaterialXGenShader.data) +cmake --build javascript/build --target JsMaterialXCore JsMaterialXGenShader + +# 4. run the viewer +cd javascript/MaterialXView && npm install && npm run start # http://localhost:8080 +``` + +Re-running the transpiler must produce byte-identical output, so a `git diff` after regeneration +cleanly surfaces any GLSL/WGSL drift. diff --git a/javascript/MaterialXView/source/helper.js b/javascript/MaterialXView/source/helper.js index 417512f666..a50153e7d3 100644 --- a/javascript/MaterialXView/source/helper.js +++ b/javascript/MaterialXView/source/helper.js @@ -21,7 +21,9 @@ export function prepareEnvTexture(texture, capabilities) { let newTexture = new THREE.DataTexture(texture.image.data, texture.image.width, texture.image.height, texture.format, texture.type); newTexture.wrapS = THREE.RepeatWrapping; - newTexture.anisotropy = capabilities.getMaxAnisotropy(); + // WebGLRenderer exposes getMaxAnisotropy(); guard for WebGPURenderer capabilities. + newTexture.anisotropy = (capabilities && typeof capabilities.getMaxAnisotropy === 'function') + ? capabilities.getMaxAnisotropy() : 1; newTexture.minFilter = THREE.LinearMipmapLinearFilter; newTexture.magFilter = THREE.LinearFilter; newTexture.generateMipmaps = true; @@ -270,7 +272,9 @@ export function getLightRotation() export function findLights(doc) { let lights = []; - for (let node of doc.getNodes()) + const nodes = doc.getNodes(); + if (!nodes) return lights; + for (let node of nodes) { if (node.getType() === "lightshader") lights.push(node); @@ -292,6 +296,7 @@ export function registerLights(mx, lights, genContext) const lightTypesBound = {}; const lightData = []; let lightId = 1; + if (!lights) return lightData; for (let light of lights) { let nodeDef = light.getNodeDef(); diff --git a/javascript/MaterialXView/source/index.js b/javascript/MaterialXView/source/index.js index 2840261a40..9998993147 100644 --- a/javascript/MaterialXView/source/index.js +++ b/javascript/MaterialXView/source/index.js @@ -8,7 +8,15 @@ import { Viewer } from './viewer.js' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { dropHandler, dragOverHandler, setLoadingCallback, setSceneLoadingCallback } from './dropHandling.js'; +// Rendering backend, selected at build time per bundle (see webpack.config.js). The WebGL +// bundle uses classic THREE.WebGLRenderer + RawShaderMaterial (ESSL); the WebGPU bundle +// aliases `three` to three/webgpu and uses WebGPURenderer + NodeMaterial (WGSL via the +// upstream WgslShaderGenerator). A toggle switches between the two HTML pages. +const BACKEND = (typeof __BACKEND__ !== 'undefined') ? __BACKEND__ : 'webgl'; +let TSL = null; // three/tsl namespace, loaded for the WebGPU backend only + let renderer, orbitControls; +let webgpuSupported = null; // cached result of navigator.gpu probe // FPS overlay state let fpsOverlay = null; @@ -50,8 +58,32 @@ function setFPSOverlayVisible(visible) { createFPSOverlay(); setFPSOverlayVisible(showFPS); -init(); -viewer.getEditor().updateProperties(0.9); +probeWebGPU().then(() => +{ + init(); + viewer.getEditor().updateProperties(0.9); +}); + +/** Probe WebGPU availability once; used by the backend toggle and init fallback. */ +async function probeWebGPU() +{ + if (webgpuSupported !== null) return webgpuSupported; + if (!navigator.gpu) + { + webgpuSupported = false; + return false; + } + try + { + const adapter = await navigator.gpu.requestAdapter(); + webgpuSupported = !!adapter; + } + catch + { + webgpuSupported = false; + } + return webgpuSupported; +} // Capture the current frame and save an image file. function captureFrame() @@ -94,11 +126,26 @@ function init() // Set up scene scene.initialize(); - // Set up renderer - renderer = new THREE.WebGLRenderer({ antialias: true, canvas }); + // Set up renderer for the selected backend. + if (BACKEND === 'webgpu') + { + if (!webgpuSupported) + { + showWebGPUFallbackBanner(); + return; + } + renderer = new THREE.WebGPURenderer({ antialias: true, canvas }); + } + else + { + renderer = new THREE.WebGLRenderer({ antialias: true, canvas }); + renderer.debug.checkShaderErrors = false; + } renderer.setSize(window.innerWidth, window.innerHeight); + // WebGL encodes sRGB in the pixel shader; WebGPU outputs linear and relies on this conversion. renderer.outputColorSpace = THREE.SRGBColorSpace; - renderer.debug.checkShaderErrors = false; + + addBackendToggle(webgpuSupported); window.addEventListener('resize', onWindowResize); @@ -134,8 +181,17 @@ function init() .then(({ default: MaterialX }) => MaterialX()) ]).then(async ([radianceTexture, irradianceTexture, lightRigXml, mxIn]) => { + // WebGPU: load the TSL namespace (used by the WGSL→NodeMaterial bridge) and + // initialize the device before the first render. + if (BACKEND === 'webgpu') + { + TSL = await import('three/tsl'); + await renderer.init(); + } + // Initialize viewer + lighting - await viewer.initialize(mxIn, renderer, radianceTexture, irradianceTexture, lightRigXml); + await viewer.initialize(mxIn, renderer, radianceTexture, irradianceTexture, lightRigXml, + { backend: BACKEND, TSL }); // Load geometry let scene = viewer.getScene(); @@ -154,7 +210,13 @@ function init() animate(); }).catch(err => { - console.error(Number.isInteger(err) ? this.getMx().getExceptionMessage(err) : err); + const mx = viewer.getMx(); + const message = (Number.isInteger(err) && mx) + ? mx.getExceptionMessage(err) + : (err && err.message ? err.message : err); + console.error(message, err); + if (BACKEND === 'webgpu') + showWebGPUFallbackBanner('WebGPU initialization failed. Use the WebGL view instead.'); }) // allow dropping files and directories @@ -187,6 +249,67 @@ function onWindowResize() renderer.setSize(window.innerWidth, window.innerHeight); } +// Floating toggle to switch rendering backend. Each backend is a separate bundle/page +// (different three build), so switching navigates between index.html and index-webgpu.html +// while preserving the current ?file/?geom query so the comparison stays on the same content. +function addBackendToggle(gpuAvailable) +{ + const search = window.location.search; + const wrap = document.createElement('div'); + // Bottom-center: clear of the material/geometry selectors (top-left), the property + // editor (top-right), and the FPS overlay (bottom-left). + wrap.style.cssText = 'position:fixed;bottom:10px;left:50%;transform:translateX(-50%);z-index:1000;' + + 'display:flex;gap:4px;flex-wrap:wrap;align-items:center;' + + 'font-family:sans-serif;font-size:12px;background:rgba(0,0,0,0.5);padding:4px 6px;border-radius:4px;max-width:90vw;'; + const label = document.createElement('span'); + label.textContent = 'Renderer:'; + label.style.cssText = 'color:#ccc;align-self:center;'; + wrap.appendChild(label); + + for (const [name, page] of [['WebGL', 'index.html'], ['WebGPU', 'index-webgpu.html']]) + { + const isWebGPU = (name === 'WebGPU'); + const active = (name.toLowerCase() === BACKEND); + const disabled = isWebGPU && gpuAvailable === false; + const btn = document.createElement('button'); + btn.textContent = name; + btn.title = disabled + ? 'WebGPU is not available in this browser' + : (isWebGPU + ? 'WebGPU uses MaterialX WGSL + TSL NodeMaterial (same light rig and IBL as WebGL)' + : 'WebGL uses full MaterialX light rig + GLSL ES shaders'); + btn.style.cssText = 'border:none;border-radius:3px;padding:3px 8px;' + + (active ? 'background:#4a9;color:#fff;font-weight:bold;' : + disabled ? 'background:#222;color:#666;cursor:not-allowed;' : + 'background:#333;color:#bbb;cursor:pointer;'); + if (!active && !disabled) + { + btn.addEventListener('click', () => + { + btn.textContent = '…'; + btn.disabled = true; + window.location.href = page + search; + }); + } + wrap.appendChild(btn); + } + document.body.appendChild(wrap); +} + +function showWebGPUFallbackBanner(message) +{ + const search = window.location.search; + const banner = document.createElement('div'); + banner.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;' + + 'background:rgba(0,0,0,0.85);color:#eee;font-family:sans-serif;font-size:14px;z-index:2000;padding:24px;text-align:center;'; + const link = 'index.html' + search; + banner.innerHTML = '

' + + (message || 'WebGPU is not available in this browser.') + + '

Open WebGL view
'; + document.body.appendChild(banner); + addBackendToggle(false); +} + function animate() { requestAnimationFrame(animate); diff --git a/javascript/MaterialXView/source/mxtsladapter.js b/javascript/MaterialXView/source/mxtsladapter.js new file mode 100644 index 0000000000..5c4f16ee0c --- /dev/null +++ b/javascript/MaterialXView/source/mxtsladapter.js @@ -0,0 +1,1226 @@ +/** + * mxtsladapter + * + * Adapts MaterialX-generated WGSL into Three.js TSL / WebGPU. + * + * The MaterialX `WgslShaderGenerator` emits a complete, standard WGSL module: + * helper structs + functions, a uniform block + texture/sampler bindings declared + * with `@group(N) @binding(M)`, and a single material entry function that returns + * the surface result. Three.js never ingests such a module directly — its only raw + * WGSL entry point is `wgslFn`, which accepts exactly one function whose resources + * arrive as *parameters* (TSL owns the bindings). + * + * This module performs that reshaping ("TSL-portable WGSL"): + * - `convertToTslPortable(wgsl, manifest)` rewrites the entry function so that the + * uniform-block members and texture/sampler bindings become explicit parameters, + * and strips the now-unused `@group/@binding` declarations and uniform struct. + * Everything else (helper structs/functions) is returned verbatim as `includes`. + * - `createMxWgslMaterial({ THREE, TSL, ... })` wires the converted entry up + * with `wgslFn`, binding each parameter to the matching TSL node + * (`uniform`/`texture`/`sampler`/builtin), and returns a NodeMaterial. + * + * The manifest is emitted alongside the WGSL by the generator and makes the + * conversion deterministic (no fragile parsing of binding layouts). + */ + +// ── WGSL text utilities ───────────────────────────────────────────────────── + +function skipWs( src, i ) { + + while ( i < src.length && /\s/.test( src[ i ] ) ) i ++; + return i; + +} + +// Escape a string for safe use as a literal inside a RegExp. The function/resource names +// here are generator-emitted WGSL identifiers, but escaping keeps the matching correct if +// that ever changes and documents the intent. +function escapeRegExp( s ) { + + return s.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); + +} + +const WGSL_TYPE_ALIGN = { + i32: 4, u32: 4, f32: 4, bool: 4, + vec2f: 8, 'vec2': 8, + vec3f: 16, 'vec3': 16, + vec4f: 16, 'vec4': 16 +}; + +const WGSL_TYPE_SIZE = { + i32: 4, u32: 4, f32: 4, bool: 4, + vec2f: 8, 'vec2': 8, + vec3f: 12, 'vec3': 12, + vec4f: 16, 'vec4': 16 +}; + +/** WGSL uniform struct layout (WebGPU host-shareable rules). */ +function layoutUniformStruct( fields ) { + + let offset = 0; + let maxAlign = 4; + const placements = []; + for ( const field of fields ) { + + const align = WGSL_TYPE_ALIGN[ field.type ] || 4; + const size = WGSL_TYPE_SIZE[ field.type ] || 4; + offset = Math.ceil( offset / align ) * align; + placements.push( { name: field.name, type: field.type, offset } ); + offset += size; + maxAlign = Math.max( maxAlign, align ); + + } + const structSize = Math.ceil( offset / maxAlign ) * maxAlign; + return { placements, structSize, vec4Stride: structSize / 16 }; + +} + +/** Parse `array` from a manifest uniform type string. */ +function parseStructArrayType( typeStr, wgslSource, numLights, lightData, layout = null ) { + + const type = typeStr || ''; + const flatVec4 = type.match( /array/ ); + if ( flatVec4 ) { + + const flatCount = parseInt( flatVec4[ 1 ], 10 ); + const stride = layout?.vec4Stride || 1; + return { + structName: 'LightData', + count: Math.ceil( flatCount / stride ), + vec4Stride: stride, + flatCount + }; + + } + const numeric = type.match( /array<(\w+),\s*(\d+)>/ ); + if ( numeric ) { + + const count = parseInt( numeric[ 2 ], 10 ); + return { + structName: numeric[ 1 ], + count, + vec4Stride: layout?.vec4Stride || 1, + flatCount: layout ? count * layout.vec4Stride : count + }; + + } + const symbolic = type.match( /array<(\w+),\s*(\w+)>/ ); + const structName = symbolic ? symbolic[ 1 ] : 'LightData'; + let count = Math.max( numLights || 0, lightData?.length || 0, 1 ); + if ( symbolic && wgslSource ) { + + const constRe = new RegExp( `const\\s+${ escapeRegExp( symbolic[ 2 ] ) }:\\s*i32\\s*=\\s*(\\d+)` ); + const m = wgslSource.match( constRe ); + if ( m ) count = parseInt( m[ 1 ], 10 ); + + } + const vec4Stride = layout?.vec4Stride || 1; + return { structName, count, vec4Stride, flatCount: count * vec4Stride }; + +} + +/** Parse `struct LightData { ... }` from generated WGSL. */ +export function parseLightDataStruct( wgsl ) { + + const m = wgsl.match( /struct\s+LightData\s*\{([^}]+)\}/ ); + if ( ! m ) return null; + const fields = []; + for ( const line of m[ 1 ].split( '\n' ) ) { + + const trimmed = line.trim().replace( /,$/, '' ); + if ( ! trimmed ) continue; + const colon = trimmed.indexOf( ':' ); + if ( colon < 0 ) continue; + fields.push( { + name: trimmed.slice( 0, colon ).trim(), + type: trimmed.slice( colon + 1 ).trim() + } ); + + } + return fields.length ? layoutUniformStruct( fields ) : null; + +} + +/** + * TSL `uniformArray` only binds `array`. Flatten `array` to + * `array` and add an unpack helper for indexed struct access. + */ +function flattenLightDataUniform( wgsl ) { + + const layout = parseLightDataStruct( wgsl ); + if ( ! layout ) return { wgsl, layout: null }; + + const maxMatch = wgsl.match( /array/ ); + let maxCount = 1; + if ( maxMatch ) { + + const sym = maxMatch[ 1 ]; + const constM = wgsl.match( new RegExp( `const\\s+${ escapeRegExp( sym ) }:\\s*i32\\s*=\\s*(\\d+)` ) ); + maxCount = constM ? parseInt( constM[ 1 ], 10 ) : 1; + + } + const flatCount = maxCount * layout.vec4Stride; + const stride = layout.vec4Stride; + + const unpackLines = [ 'var out: LightData;' ]; + for ( const p of layout.placements ) { + + const vec4Index = Math.floor( p.offset / 16 ); + const comp = [ 'x', 'y', 'z', 'w' ][ ( p.offset % 16 ) / 4 ]; + if ( p.type === 'vec3f' || p.type === 'vec3' ) { + + unpackLines.push( `out.${ p.name } = data[base + ${ vec4Index }u].xyz;` ); + + } else if ( p.type === 'i32' ) { + + unpackLines.push( `out.${ p.name } = bitcast(data[base + ${ vec4Index }u].${ comp });` ); + + } else if ( p.type === 'f32' ) { + + unpackLines.push( `out.${ p.name } = data[base + ${ vec4Index }u].${ comp };` ); + + } + + } + const helper = [ + `fn mx_lightData_at(data: array, index: i32) -> LightData {`, + `\tlet base: u32 = u32(index) * ${ stride }u;`, + ...unpackLines.map( ( l ) => `\t${ l }` ), + '\treturn out;', + '}' + ].join( '\n' ); + + let out = wgsl.replace( /struct\s+LightData\s*\{/, `${ helper }\n\nstruct LightData {` ); + out = out.replace( /array]+>/g, `array` ); + out = out.replace( /u_lightData\[([^\]]+)\]/g, 'mx_lightData_at(u_lightData, $1)' ); + return { wgsl: out, layout }; + +} + +function vec3ToArray( v ) { + + if ( Array.isArray( v ) ) return v; + if ( v && v.x != null ) return [ v.x, v.y, v.z ]; + return [ 0, 0, 0 ]; + +} + +// Reused buffer for packing i32 fields into vec4f slots (WGSL unpack uses bitcast). +const _intBitsView = new DataView( new ArrayBuffer( 4 ) ); + +/** Store an i32 in a float32 uniform slot preserving bit pattern for WGSL bitcast. */ +function floatFromIntBits( i32 ) { + + _intBitsView.setInt32( 0, i32 | 0, true ); + return _intBitsView.getFloat32( 0, true ); + +} + +/** + * Pack light slots into a flat vec4 array matching WGSL uniform struct layout. + * Inactive slots are zeroed so the WGSL light loop can skip them via u_numActiveLightSources. + */ +export function packLightDataToVec4Array( lightData, maxCount, layout ) { + + const vec4Count = maxCount * layout.vec4Stride; + const floats = new Array( vec4Count * 4 ).fill( 0 ); + const floatStride = layout.vec4Stride * 4; + for ( let i = 0; i < maxCount; i ++ ) { + + const l = lightData && lightData[ i ]; + const base = i * floatStride; + if ( ! l ) continue; + for ( const p of layout.placements ) { + + const floatIndex = base + p.offset / 4; + if ( p.type === 'i32' || p.type === 'u32' ) { + + let intVal = 0; + if ( p.name === 'light_type' || p.name === 'type' ) { + + intVal = l.light_type != null ? l.light_type : ( l.type | 0 ); + + } else if ( l[ p.name ] != null ) { + + intVal = l[ p.name ] | 0; + + } + floats[ floatIndex ] = floatFromIntBits( intVal ); + + } else if ( p.name === 'direction' ) { + + const d = vec3ToArray( l.direction ); + floats[ floatIndex ] = d[ 0 ]; + floats[ floatIndex + 1 ] = d[ 1 ]; + floats[ floatIndex + 2 ] = d[ 2 ]; + + } else if ( p.name === 'color' ) { + + const c = vec3ToArray( l.color ); + floats[ floatIndex ] = c[ 0 ]; + floats[ floatIndex + 1 ] = c[ 1 ]; + floats[ floatIndex + 2 ] = c[ 2 ]; + + } else if ( p.name === 'intensity' ) { + + floats[ floatIndex ] = l.intensity != null ? l.intensity : 0; + + } + + } + + } + const out = []; + for ( let i = 0; i < vec4Count; i ++ ) { + + const b = i * 4; + out.push( { x: floats[ b ], y: floats[ b + 1 ], z: floats[ b + 2 ], w: floats[ b + 3 ] } ); + + } + return out; + +} + +/** + * Extract a top-level `fn ( ) -> { }` by name. + * Returns { start, end, params, ret, body } where [start,end) spans the whole + * definition in `src`. Throws if not found. + */ +function extractFunction( src, name ) { + + const re = new RegExp( `\\bfn\\s+${ escapeRegExp( name ) }\\s*\\(`, 'g' ); + const m = re.exec( src ); + if ( m === null ) throw new Error( `mxtsladapter: entry function '${ name }' not found` ); + + const start = m.index; + let i = m.index + m[ 0 ].length - 1; // at '(' + + // Match the parameter-list parentheses. + let depth = 0; + const paramStart = i + 1; + let paramEnd = - 1; + for ( ; i < src.length; i ++ ) { + + if ( src[ i ] === '(' ) depth ++; + else if ( src[ i ] === ')' ) { depth --; if ( depth === 0 ) { paramEnd = i; break; } } + + } + + if ( paramEnd < 0 ) throw new Error( `mxtsladapter: unterminated parameter list for '${ name }'` ); + + const params = src.slice( paramStart, paramEnd ); + + // Optional `-> type` then the body braces. + let j = skipWs( src, paramEnd + 1 ); + let ret = ''; + if ( src[ j ] === '-' && src[ j + 1 ] === '>' ) { + + j = skipWs( src, j + 2 ); + const retStart = j; + while ( j < src.length && src[ j ] !== '{' ) j ++; + ret = src.slice( retStart, j ).trim(); + + } + + if ( src[ j ] !== '{' ) throw new Error( `mxtsladapter: missing body for '${ name }'` ); + + const bodyStart = j; + depth = 0; + let bodyEnd = - 1; + for ( ; j < src.length; j ++ ) { + + if ( src[ j ] === '{' ) depth ++; + else if ( src[ j ] === '}' ) { depth --; if ( depth === 0 ) { bodyEnd = j + 1; break; } } + + } + + if ( bodyEnd < 0 ) throw new Error( `mxtsladapter: unterminated body for '${ name }'` ); + + return { + start, + end: bodyEnd, + params: params.trim(), + ret, + body: src.slice( bodyStart + 1, bodyEnd - 1 ) // inside the outer braces + }; + +} + +/** Remove every line that declares a `@group(...) @binding(...)` resource. */ +function removeBindingDecls( src ) { + + return src + .split( '\n' ) + .filter( ( line ) => ! /^\s*@group\s*\(/.test( line ) ) + .join( '\n' ); + +} + +/** Index of the `)` matching the `(` at `openIdx` in `src`. */ +function matchParen( src, openIdx ) { + + let depth = 0; + for ( let i = openIdx; i < src.length; i ++ ) { + + if ( src[ i ] === '(' ) depth ++; + else if ( src[ i ] === ')' ) { depth --; if ( depth === 0 ) return i; } + + } + return - 1; + +} + +/** + * Thread private/global HW resources through the call chain. + * + * The MaterialX library functions (IBL, transmission, lighting) reference the HW private + * uniforms + env maps as *module-scope globals* (`u_envMatrix`, `u_viewPosition`, + * `u_lightData`, the env textures, …). Three.js `wgslFn` forbids module-scope resources in + * `includes`, so any function that (transitively) touches one of them must receive it as a + * parameter, and every call site must forward it. + * + * Only these private/global resources are threaded. Public material inputs (semantic + * `uniform`) and material image textures (semantic `texture`/`sampler`) must NOT be threaded: + * they are part of the MaterialX node interface and are already passed explicitly down the + * node-graph call chain as named parameters (e.g. `NG_..._surfaceshader_100(base, base_color, …)`). + * Threading them would re-append them to functions that already declare them as parameters or + * use them as local variable names, producing WGSL "redeclaration of '…'" errors. + * + * The entry function is left unmodified — `convertToTslPortable` adds the resources to it as + * parameters from the manifest, and call sites in the entry body forward them. + * + * @param {string} wgsl - The full generated WGSL module. + * @param {Object} manifest - Generator manifest (uniforms + textures after normalization). + * @return {string} The rewritten module (unchanged if there are no private resources). + */ +export function threadEnvResources( wgsl, manifest ) { + + // The generator references geometry varyings through the MaterialX vertex-data instance + // `vd` (e.g. `vd.normalWorld`). The entry exposes the `VertexData` members as flat + // parameters (and the assembler binds them to TSL builtins), so flatten the struct access + // to bare member names module-wide. The varyings are then threaded like any other global + // below, so library helpers (e.g. the surface-shader node) that read them get them as + // parameters forwarded from the entry. `\bvd\.` only matches the vertex-data instance. + wgsl = wgsl.replace( /\bvd\./g, '' ); + + // The native WgslShaderGenerator threads the whole `vd: VertexData` struct into closure / + // surface-shader functions as an explicit parameter (and forwards `vd` at each call site), + // because standalone WGSL has no module-scope varyings. The TSL bridge instead threads the + // individual varyings (flattened to bare names above and re-added as params below), so the + // struct parameter and its forwarded argument are redundant — strip them. The entry's sole + // `vd: VertexData` parameter is intentionally left intact so `convertToTslPortable` can still + // detect the struct entry and rebuild it from the manifest's flat varying members. + wgsl = wgsl.replace( /\bvd\s*:\s*VertexData\s*,\s*/g, '' ); // first parameter of a helper + wgsl = wgsl.replace( /,\s*vd\s*:\s*VertexData\b/g, '' ); // later parameter of a helper + wgsl = wgsl.replace( /\(\s*vd\s*,\s*/g, '(' ); // forwarded `vd` as first argument + wgsl = wgsl.replace( /\(\s*vd\s*\)/g, '()' ); // forwarded `vd` as sole argument + + // A resource is "private/global" (referenced as a module-scope global inside library + // helpers, hence threaded) when it is an env map or a non-public uniform. Public material + // inputs (`uniform`) and material image textures (`texture`/`sampler`) flow through the + // node-graph parameter chain and are excluded. + const isPrivateTexture = ( t ) => ( t.semantic || '' ).startsWith( 'env:' ); + const isPrivateUniform = ( u ) => ( u.semantic || 'uniform' ) !== 'uniform'; + + // Ordered resource list (signature decls + call args), data-driven from the manifest. + const resourceParts = []; + + // Vertex-data varyings (now bare member names after the `vd.` flatten above). These are + // genuine module inputs that helpers reference globally, so they must be threaded too. + for ( const p of manifest.entryParams || [] ) { + + if ( ! ( p.semantic || '' ).startsWith( 'varying:' ) ) continue; + resourceParts.push( { decl: `${ p.name }: ${ p.type }`, arg: p.name } ); + + } + for ( const t of manifest.textures || [] ) { + + if ( ! isPrivateTexture( t ) ) continue; + resourceParts.push( { decl: `${ t.texture }: ${ t.wgslType }`, arg: t.texture } ); + resourceParts.push( { decl: `${ t.sampler }: sampler`, arg: t.sampler } ); + + } + for ( const u of manifest.uniforms || [] ) { + + if ( ! isPrivateUniform( u ) ) continue; + resourceParts.push( { decl: `${ u.name }: ${ u.type }`, arg: u.name } ); + + } + if ( resourceParts.length === 0 ) return wgsl; + + const resourceNames = resourceParts.map( ( p ) => p.arg ); + const paramsStr = resourceParts.map( ( p ) => p.decl ).join( ', ' ); + const argsStr = resourceParts.map( ( p ) => p.arg ).join( ', ' ); + + // Enumerate all top-level `fn name( params ) ... { body }` definitions. + const fnRe = /\bfn\s+([A-Za-z_]\w*)\s*\(/g; + const fns = []; + for ( let m = fnRe.exec( wgsl ); m !== null; m = fnRe.exec( wgsl ) ) { + + const openParen = m.index + m[ 0 ].length - 1; + const closeParen = matchParen( wgsl, openParen ); + const braceOpen = wgsl.indexOf( '{', closeParen ); + // Match the body braces to know where this function ends. + let depth = 0, braceEnd = - 1; + for ( let i = braceOpen; i < wgsl.length; i ++ ) { + + if ( wgsl[ i ] === '{' ) depth ++; + else if ( wgsl[ i ] === '}' ) { depth --; if ( depth === 0 ) { braceEnd = i; break; } } + + } + fns.push( { name: m[ 1 ], openParen, closeParen, bodyStart: braceOpen, bodyEnd: braceEnd } ); + + } + + const byName = Object.fromEntries( fns.map( ( f ) => [ f.name, f ] ) ); + const bodyOf = ( f ) => wgsl.slice( f.bodyStart, f.bodyEnd + 1 ); + + // Fixpoint: a function "needs" resources if its body references one directly, or + // calls another function that needs them. + const needs = new Set(); + const refsResource = ( body ) => resourceNames.some( ( n ) => new RegExp( `\\b${ escapeRegExp( n ) }\\b` ).test( body ) ); + for ( const f of fns ) if ( refsResource( bodyOf( f ) ) ) needs.add( f.name ); + + for ( let changed = true; changed; ) { + + changed = false; + for ( const f of fns ) { + + if ( needs.has( f.name ) ) continue; + const body = bodyOf( f ); + for ( const callee of needs ) { + + if ( new RegExp( `\\b${ escapeRegExp( callee ) }\\s*\\(` ).test( body ) ) { needs.add( f.name ); changed = true; break; } + + } + + } + + } + + if ( needs.size === 0 ) return wgsl; + + const entryName = manifest.entry; + + // Collect insertions (index -> text). Apply right-to-left so indices stay valid. + const inserts = []; + + // 1. Append params to the signature of each needing function (except the entry). + for ( const f of fns ) { + + if ( ! needs.has( f.name ) || f.name === entryName ) continue; + const hasParams = wgsl.slice( f.openParen + 1, f.closeParen ).trim().length > 0; + inserts.push( { at: f.closeParen, text: ( hasParams ? ', ' : '' ) + paramsStr } ); + + } + + // 2. Append args at every call site of a needing function (anywhere in the module). + for ( const callee of needs ) { + + const callRe = new RegExp( `\\b${ escapeRegExp( callee ) }\\s*\\(`, 'g' ); + for ( let m = callRe.exec( wgsl ); m !== null; m = callRe.exec( wgsl ) ) { + + // Skip the definition itself (`fn callee(`). + const before = wgsl.slice( Math.max( 0, m.index - 4 ), m.index ); + if ( /\bfn\s$/.test( before ) ) continue; + const open = m.index + m[ 0 ].length - 1; + const close = matchParen( wgsl, open ); + if ( close < 0 ) continue; + const hasArgs = wgsl.slice( open + 1, close ).trim().length > 0; + inserts.push( { at: close, text: ( hasArgs ? ', ' : '' ) + argsStr } ); + + } + + } + + inserts.sort( ( a, b ) => b.at - a.at ); + let out = wgsl; + for ( const ins of inserts ) out = out.slice( 0, ins.at ) + ins.text + out.slice( ins.at ); + return out; + +} + +// ── Reflection normalization (neutral C++ → TSL semantics) ───────────────── + +function varyingSemantic( name ) { + + if ( name.includes( 'normal' ) ) return 'varying:normalWorld'; + if ( name.includes( 'tangent' ) ) return 'varying:tangentWorld'; + if ( name.includes( 'position' ) ) return 'varying:positionWorld'; + return 'varying:uv'; + +} + +function buildTslSemantics( binding ) { + + const { name, role } = binding; + const HOST = { + u_viewPosition: 'camera:viewPosition', + u_numActiveLightSources: 'light:count', + u_lightData: 'light:data', + u_envMatrix: 'env:matrix', + u_envRadianceMips: 'env:radianceMips', + u_envRadianceSamples: 'env:radianceSamples', + u_envLightIntensity: 'env:lightIntensity' + }; + if ( HOST[ name ] ) return HOST[ name ]; + if ( name === 'u_lightDirection' ) return 'light:direction'; + if ( name === 'u_lightColor' ) return 'light:color'; + if ( role === 'lightData' ) return 'light:data'; + if ( role === 'uniform' ) return 'uniform'; + if ( role === 'texture' ) { + + if ( name.startsWith( 'u_envRadiance' ) ) return 'env:radiance'; + if ( name.startsWith( 'u_envIrradiance' ) ) return 'env:irradiance'; + return 'texture'; + + } + if ( role === 'sampler' ) { + + if ( name.startsWith( 'u_envRadiance' ) ) return 'env:radiance:sampler'; + if ( name.startsWith( 'u_envIrradiance' ) ) return 'env:irradiance:sampler'; + return 'sampler'; + + } + if ( role === 'host' ) return HOST[ name ] || 'host'; + return 'uniform'; + +} + +/** + * Normalize generator reflection into the legacy manifest shape expected by the + * TSL bridge. Accepts both the old manifest (entry string + uniforms/textures) + * and the new neutral format (entry object + bindings + struct entryParams). + * + * @param {Object} reflection - Generator reflection or legacy manifest. + * @return {Object} Manifest with TSL semantics on uniforms, textures, entryParams. + */ +export function normalizeReflection( reflection ) { + + if ( ! reflection ) return reflection; + + // Legacy manifests already carry TSL semantics — return unchanged. + if ( reflection.uniforms?.some( ( u ) => u.semantic ) || reflection.textures?.some( ( t ) => t.semantic ) ) + return reflection; + + if ( ! reflection.bindings ) return reflection; + + const entry = typeof reflection.entry === 'string' + ? reflection.entry + : reflection.entry?.pixel; + + const uniforms = []; + const textures = []; + const textureKeys = new Set(); + + for ( const b of reflection.bindings ) { + + const semantic = buildTslSemantics( b ); + if ( b.role === 'texture' ) { + + const key = b.key || b.name.replace( /_texture$/, '' ); + textures.push( { + key, + texture: b.name, + sampler: null, + wgslType: b.type, + semantic, + file: b.file + } ); + textureKeys.add( key ); + + } else if ( b.role === 'sampler' ) { + + const key = b.key || b.name.replace( /_sampler$/, '' ); + const tex = textures.find( ( t ) => t.key === key ); + if ( tex ) tex.sampler = b.name; + + } else { + + uniforms.push( { + name: b.name, + type: b.type, + semantic, + value: b.value + } ); + + } + + } + + for ( const t of textures ) { + + if ( ! t.sampler ) t.sampler = `${ t.key }_sampler`; + + } + + let entryParams = reflection.entryParams || []; + if ( entryParams.length === 1 && entryParams[ 0 ].members ) { + + entryParams = entryParams[ 0 ].members.map( ( m ) => ( { + name: m.name, + type: m.type, + semantic: varyingSemantic( m.name ) + } ) ); + + } else { + + entryParams = entryParams.map( ( p ) => ( { + ...p, + semantic: p.semantic || varyingSemantic( p.name ) + } ) ); + + } + + return { + entry, + output: reflection.output, + entryParams, + uniforms, + textures + }; + +} + +// ── Conversion ────────────────────────────────────────────────────────────── + +/** + * Convert a complete MaterialX WGSL module into a TSL-portable form. + * + * @param {string} wgsl - The generated WGSL module. + * @param {Object} manifest - Resource description emitted by the generator. + * @return {{ name:string, entry:string, includes:string, params:Array }} + */ +export function convertToTslPortable( wgsl, manifest ) { + + manifest = normalizeReflection( manifest ); + + // Thread module-scope resources through the call chain so `includes` receive them + // as parameters rather than globals (required by Three.js `wgslFn`). + wgsl = threadEnvResources( wgsl, manifest ); + + // Three.js uniformArray only supports primitive array types (vec4/mat4), not struct arrays. + const lightFlatten = flattenLightDataUniform( wgsl ); + wgsl = lightFlatten.wgsl; + const lightLayout = lightFlatten.layout; + + const entryName = manifest.entry; + const fn = extractFunction( wgsl, entryName ); + + const uniforms = manifest.uniforms || []; + const textures = manifest.textures || []; + const entryParams = manifest.entryParams || []; + + // The generated entry already takes the geometry varyings as parameters and + // references uniforms/textures by bare name as module-scope `@group/@binding` + // resources. Hoist those resources into the parameter list (bodies use bare + // names, so no in-body rewriting is needed) and strip the binding declarations. + const newParams = []; + const params = []; + + // 1. Existing entry parameters (varyings). Neutral reflection may pass vertex + // data as a single struct parameter — flatten it for wgslFn and rewrite the body. + const semanticByName = Object.fromEntries( entryParams.map( ( p ) => [ p.name, p.semantic ] ) ); + const flatParams = splitParams( fn.params ); + const isStructEntry = flatParams.length === 1 && entryParams.length > 0 && + entryParams[ 0 ].name !== flatParams[ 0 ].name; + let body = fn.body; + if ( isStructEntry ) { + + const structName = flatParams[ 0 ].name; + for ( const m of entryParams ) { + + newParams.push( `${ m.name }: ${ m.type }` ); + params.push( { name: m.name, type: m.type, semantic: m.semantic || varyingSemantic( m.name ) } ); + + } + body = body.replace( new RegExp( `\\b${ escapeRegExp( structName ) }\\.`, 'g' ), '' ); + + } else { + + for ( const p of flatParams ) { + + newParams.push( `${ p.name }: ${ p.type }` ); + params.push( { name: p.name, type: p.type, semantic: semanticByName[ p.name ] || 'varying:uv' } ); + + } + + } + + // 2. Uniforms become individual parameters (bare names already used in body). + // Most carry semantic 'uniform'; surface lighting inputs carry camera:/light:. + for ( const u of uniforms ) { + + let paramType = u.type; + if ( ( u.semantic || '' ) === 'light:data' && lightLayout ) { + + const { flatCount } = parseStructArrayType( u.type, wgsl, null, null, lightLayout ); + paramType = `array`; + + } + newParams.push( `${ u.name }: ${ paramType }` ); + params.push( { name: u.name, type: paramType, semantic: u.semantic || 'uniform', value: u.value } ); + + } + + // 3. Each texture contributes a texture + sampler parameter pair. Material image + // textures carry semantic 'texture'; env (IBL) maps carry 'env:radiance' / + // 'env:irradiance' so the assembler can bind them from the environment instead. + for ( const tex of textures ) { + + const texSemantic = tex.semantic && tex.semantic.startsWith( 'env:' ) ? tex.semantic : 'texture'; + const sampSemantic = texSemantic === 'texture' ? 'sampler' : texSemantic + ':sampler'; + newParams.push( `${ tex.texture }: ${ tex.wgslType }` ); + newParams.push( `${ tex.sampler }: sampler` ); + params.push( { name: tex.texture, type: tex.wgslType, semantic: texSemantic, key: tex.key } ); + params.push( { name: tex.sampler, type: 'sampler', semantic: sampSemantic, key: tex.key } ); + + } + + // Strip entry-point IO attributes (e.g. `@location(0)`, `@builtin(...)`) from the return + // type: they are required on the standalone `@fragment` entry (WGSL validation) but invalid + // on the plain function that Three.js `wgslFn` turns this into. + const retType = fn.ret ? fn.ret.replace( /@\w+\s*\([^)]*\)\s*/g, '' ).trim() : ''; + const retArrow = retType ? ` -> ${ retType }` : ''; + const entry = `fn ${ entryName }( ${ newParams.join( ', ' ) } )${ retArrow } {${ body }}`; + + // Includes: the original module minus the entry and the binding declarations. + let includes = wgsl.slice( 0, fn.start ) + wgsl.slice( fn.end ); + includes = removeBindingDecls( includes ).trim(); + + // The generator emits the stage attribute on the line *before* `fn ` (e.g. + // `@fragment\nfn fragmentMain(...)`) for standalone WGSL validation. `extractFunction` + // starts at `fn`, so that attribute is left dangling in the includes and would re-attach + // to the next function Three.js emits — turning a plain helper into a bogus entry point + // ("missing entry point IO attribute on parameter"). Strip any stray stage attributes. + includes = includes.replace( /@(?:fragment|vertex|compute)\b\s*/g, '' ).trim(); + + // The vertex-data struct is now unused: the entry takes flat varying parameters and the + // helper functions were de-threaded (see threadEnvResources). It also carries @builtin / + // @location IO attributes that are only valid at an entry boundary, so leaving its + // definition in the wgslFn includes would be rejected. Strip it. + if ( isStructEntry ) { + + const structType = flatParams[ 0 ].type; + includes = includes + .replace( new RegExp( `struct\\s+${ escapeRegExp( structType ) }\\s*\\{[^}]*\\}\\s*`, 'g' ), '' ) + .trim(); + + } + + return { name: entryName, entry, includes, params }; + +} + +/** Split a WGSL parameter list ("a: T1, b: T2") into [{name,type}]. */ +function splitParams( params ) { + + const out = []; + let depth = 0, start = 0; + const pieces = []; + for ( let i = 0; i < params.length; i ++ ) { + + const c = params[ i ]; + if ( c === '<' || c === '(' ) depth ++; + else if ( c === '>' || c === ')' ) depth --; + else if ( c === ',' && depth === 0 ) { pieces.push( params.slice( start, i ) ); start = i + 1; } + + } + const tail = params.slice( start ).trim(); + if ( tail ) pieces.push( tail ); + + for ( const piece of pieces ) { + + const colon = piece.indexOf( ':' ); + if ( colon < 0 ) continue; + out.push( { name: piece.slice( 0, colon ).trim(), type: piece.slice( colon + 1 ).trim() } ); + + } + return out; + +} + +// ── Material assembly ──────────────────────────────────────────────────────── + +/** + * Build a Three.js NodeMaterial from MaterialX WGSL + manifest. + * + * @param {Object} options + * @param {Object} options.THREE - The `three/webgpu` namespace. + * @param {Object} options.TSL - The `three/tsl` namespace. + * @param {string} options.wgsl - Generated WGSL module. + * @param {Object} options.manifest - Generator manifest. + * @param {Object} [options.textures] - Map of texture key -> Texture. + * @param {Node} [options.uvNode] - Optional uv node (defaults to TSL `uv()`). + * @param {boolean} [options.useGeometryTangent] - Use TSL `tangentWorld` when geometry tangents exist. + * @return {NodeMaterial} + */ +export function createMxWgslMaterial( { THREE, TSL, wgsl, manifest, textures = {}, uvNode = null, light = null, lightData = null, numLights = null, environment = null, useGeometryTangent = false } ) { + + manifest = normalizeReflection( manifest ); + + const { wgslFn, wgsl: wgslCode, uniform, uniformArray, texture, sampler, uv, normalWorld, positionWorld, cameraPosition, tangentWorld: tangentWorldBuiltin, vec3, float, select } = TSL; + + // IBL environment: equirect radiance (mipped) + irradiance maps and FIS parameters. + // When the shader was generated with FIS but no environment is supplied, bind a 1×1 + // black fallback so the module still compiles (IBL simply contributes nothing). + const env = environment || {}; + const fallbackEnvTex = () => { + + const t = new THREE.DataTexture( new Uint8Array( [ 0, 0, 0, 255 ] ), 1, 1 ); + t.needsUpdate = true; + return t; + + }; + const envRadianceTex = env.radiance || fallbackEnvTex(); + const envIrradianceTex = env.irradiance || envRadianceTex; + // HDR env maps must stay linear (see viewer buildEnvironment). + envRadianceTex.colorSpace = THREE.NoColorSpace; + envIrradianceTex.colorSpace = THREE.NoColorSpace; + const envTexFor = ( semantic ) => semantic.startsWith( 'env:irradiance' ) ? envIrradianceTex : envRadianceTex; + const fallbackMatTex = () => { + + const t = new THREE.DataTexture( new Uint8Array( [ 128, 128, 128, 255 ] ), 1, 1 ); + // DataTexture defaults to Nearest+Nearest; Three.js treats that as "unfilterable" and + // omits nodeUniformN_sampler bindings while MaterialX WGSL still passes samplers. + t.magFilter = THREE.LinearFilter; + t.minFilter = THREE.LinearFilter; + t.needsUpdate = true; + return t; + + }; + + // Three.js only emits sampler bindings for filterable textures (see WebGPUNodeBuilder + // needsSampler). MaterialX WGSL always declares matching sampler parameters. + const ensureFilterableTexture = ( tex ) => { + + if ( ! tex ) return tex; + if ( tex.magFilter === THREE.NearestFilter ) tex.magFilter = THREE.LinearFilter; + if ( tex.minFilter === THREE.NearestFilter || tex.minFilter === THREE.NearestMipmapNearestFilter ) { + + tex.minFilter = tex.generateMipmaps !== false ? THREE.LinearMipmapLinearFilter : THREE.LinearFilter; + + } + return tex; + + }; + + // A robust world-space tangent. MaterialX BSDFs orthogonalize the tangent against + // the normal (`normalize(T - dot(T,N)*N)`), which yields NaN if T is zero or parallel + // to N — and most geometries (e.g. TorusKnotGeometry) carry no tangent attribute, so + // a raw `tangentWorld` accessor would be degenerate. Derive an arbitrary orthonormal + // tangent from the normal instead; for isotropic GGX the exact direction is irrelevant. + function derivedTangentWorld() { + + const n = normalWorld.normalize(); + // Reference axis least aligned with the normal, to avoid a parallel cross product. + const ref = select( n.y.abs().lessThan( float( 0.99 ) ), vec3( 0, 1, 0 ), vec3( 1, 0, 0 ) ); + return ref.cross( n ).normalize(); + + } + // MaterialXView computes tangents for indexed geometry; use them when available for + // anisotropic parity with the WebGL path. Fall back to a derived tangent otherwise. + const tangentWorld = useGeometryTangent ? tangentWorldBuiltin : derivedTangentWorld(); + + const converted = convertToTslPortable( wgsl, manifest ); + const wgslForConsts = converted.includes + '\n' + converted.entry; + + const includesNode = converted.includes.length ? [ wgslCode( converted.includes ) ] : []; + const entryFn = wgslFn( converted.entry, includesNode ); + + const builtin = { + 'varying:uv': () => ( uvNode || uv() ), + 'varying:normalWorld': () => normalWorld, + 'varying:positionWorld': () => positionWorld, + 'varying:tangentWorld': () => tangentWorld, + 'camera:viewPosition': () => cameraPosition + }; + + const args = {}; + // One TSL texture() node per manifest texture key. wgslFn passes texture and sampler as + // separate WGSL parameters; calling texture() twice (even with the same THREE.Texture) + // creates two binding slots (nodeUniformN + nodeUniformN+1_sampler) and breaks pairing. + const textureNodes = {}; + const textureNodeFor = ( key, threeTex ) => { + + if ( ! textureNodes[ key ] ) textureNodes[ key ] = texture( ensureFilterableTexture( threeTex ) ); + return textureNodes[ key ]; + + }; + + for ( const p of converted.params ) { + + if ( p.semantic === 'uniform' ) { + + args[ p.name ] = makeUniformNode( uniform, toUniformValue( THREE, p ), p.type ); + + } else if ( p.semantic === 'light:direction' ) { + + args[ p.name ] = uniform( ( light && light.direction ) || toUniformValue( THREE, p ) ); + + } else if ( p.semantic === 'light:color' ) { + + args[ p.name ] = uniform( ( light && light.color ) || toUniformValue( THREE, p ) ); + + } else if ( p.semantic === 'light:count' ) { + + args[ p.name ] = uniform( ( numLights != null ? numLights : p.value ) | 0, 'int' ); + + } else if ( p.semantic === 'light:data' ) { + + const layout = parseLightDataStruct( wgslForConsts ); + const { count, flatCount } = parseStructArrayType( p.type, wgslForConsts, null, lightData, layout ); + const packed = layout + ? packLightDataToVec4Array( lightData || p.value, count, layout ) + : new Array( flatCount ).fill( { x: 0, y: 0, z: 0, w: 0 } ); + args[ p.name ] = uniformArray( packed, 'vec4' ); + + } else if ( p.semantic === 'host' ) { + + args[ p.name ] = makeUniformNode( uniform, toUniformValue( THREE, p ), p.type ); + + } else if ( p.semantic === 'texture' ) { + + args[ p.name ] = textureNodeFor( p.key, textures[ p.key ] || fallbackMatTex() ); + + } else if ( p.semantic === 'sampler' ) { + + args[ p.name ] = sampler( textureNodeFor( p.key, textures[ p.key ] || fallbackMatTex() ) ); + + } else if ( p.semantic === 'env:radiance' || p.semantic === 'env:irradiance' ) { + + args[ p.name ] = textureNodeFor( p.key, envTexFor( p.semantic ) ); + + } else if ( p.semantic === 'env:radiance:sampler' || p.semantic === 'env:irradiance:sampler' ) { + + const envSemantic = p.semantic.replace( ':sampler', '' ); + args[ p.name ] = sampler( textureNodeFor( p.key, envTexFor( envSemantic ) ) ); + + } else if ( p.semantic === 'env:matrix' ) { + + args[ p.name ] = uniform( env.matrix || matrixFromArray( THREE, p.value ) ); + + } else if ( p.semantic === 'env:radianceSamples' ) { + + args[ p.name ] = uniform( ( env.samples != null ? env.samples : p.value ) | 0, 'int' ); + + } else if ( p.semantic === 'env:radianceMips' ) { + + args[ p.name ] = uniform( ( env.mips != null ? env.mips : p.value ) | 0, 'int' ); + + } else if ( p.semantic === 'env:lightIntensity' ) { + + args[ p.name ] = uniform( env.intensity != null ? env.intensity : p.value ); + + } else if ( p.semantic && ( p.semantic.startsWith( 'varying:' ) || p.semantic.startsWith( 'camera:' ) ) ) { + + const make = builtin[ p.semantic ]; + if ( ! make ) throw new Error( `mxtsladapter: unknown builtin semantic '${ p.semantic }'` ); + args[ p.name ] = make(); + + } else { + + throw new Error( `mxtsladapter: unhandled parameter semantic '${ p.semantic }' for '${ p.name }'` ); + + } + + } + + const material = new THREE.MeshBasicNodeMaterial(); + // MaterialX WGSL already includes direct lights + IBL; do not run Three.js scene lighting. + material.lights = false; + material.fog = false; + material.colorNode = entryFn( args ); + // Bound argument nodes, keyed by parameter name. `createMxWgslGUI` reads these to + // drive live uniform edits, so this is a functional binding handle, not debug state. + material.userData.mxArgs = args; + return material; + +} + +function toUniformValue( THREE, p ) { + + const v = p.value; + if ( v === undefined || v === null ) { + + // Sensible zero/identity defaults by type. + if ( p.type === 'f32' || p.type === 'i32' || p.type === 'u32' ) return 0; + if ( p.type === 'vec2f' ) return new THREE.Vector2(); + if ( p.type === 'vec4f' ) return new THREE.Vector4(); + return new THREE.Vector3(); + + } + + if ( typeof v === 'boolean' && ( p.type === 'i32' || p.type === 'u32' ) ) return v ? 1 : 0; + + if ( Array.isArray( v ) ) { + + if ( v.length === 2 ) return new THREE.Vector2( v[ 0 ], v[ 1 ] ); + if ( v.length === 3 ) return new THREE.Vector3( v[ 0 ], v[ 1 ], v[ 2 ] ); + if ( v.length === 4 ) return new THREE.Vector4( v[ 0 ], v[ 1 ], v[ 2 ], v[ 3 ] ); + if ( v.length === 16 ) return matrixFromArray( THREE, v ); + + } + + return v; + +} + +/** Bind a TSL uniform node with the correct scalar type for WGSL i32/u32 host inputs. */ +function makeUniformNode( uniformFn, value, wgslType ) { + + if ( wgslType === 'i32' || wgslType === 'u32' ) return uniformFn( value | 0, 'int' ); + return uniformFn( value ); + +} + +/** Build a THREE.Matrix4 from a 16-element column-major array (WGSL mat4x4f layout). */ +function matrixFromArray( THREE, v ) { + + const m = new THREE.Matrix4(); + if ( Array.isArray( v ) && v.length === 16 ) { + + // Three stores column-major internally; WGSL mat4x4f is also column-major, so the + // array maps directly into Matrix4.elements. + m.elements = v.slice(); + + } + return m; + +} + +// ── Live parameter editor ───────────────────────────────────────────────────── + +// Slider range heuristics for scalar surface inputs, keyed by name substring. +function scalarRange( name ) { + + if ( /IOR$/.test( name ) ) return [ 1, 3 ]; + if ( /thickness/.test( name ) ) return [ 0, 2000 ]; // thin-film, nanometres + if ( /roughness|metalness|anisotropy|rotation|affect|walled/.test( name ) ) return [ 0, 1 ]; + if ( /emission|scale|depth|dispersion/.test( name ) ) return [ 0, 5 ]; + return [ 0, 1 ]; + +} + +const GUI_CATEGORIES = [ 'base', 'diffuse', 'metalness', 'specular', 'transmission', + 'subsurface', 'sheen', 'coat', 'thin_film', 'thin_walled', 'emission', 'opacity' ]; + +function guiCategory( stripped ) { + + for ( const c of GUI_CATEGORIES ) if ( stripped === c || stripped.startsWith( c + '_' ) ) return c; + return 'other'; + +} + +/** + * Build a live lil-gui editor for a material created by `createMxWgslMaterial`. + * + * Drives the bound TSL uniform nodes (`material.userData.mxArgs`) directly, so edits take + * effect on the next render with no rebuild. Surface inputs are read from the manifest and + * grouped into folders by closure (base / specular / coat / …); the directional light and + * environment intensity get their own folder. + * + * @param {Object} options + * @param {Function} options.GUI - The lil-gui constructor. + * @param {Object} options.THREE - The `three/webgpu` namespace. + * @param {NodeMaterial} options.material - Material from `createMxWgslMaterial`. + * @param {Object} options.manifest - The generator manifest used to build the material. + * @param {GUI} [options.gui] - Existing gui to populate (otherwise a new one is created). + * @return {GUI} + */ +export function createMxWgslGUI( { GUI, THREE, material, manifest, gui = null } ) { + + manifest = normalizeReflection( manifest ); + gui = gui || new GUI(); + const args = material.userData.mxArgs || {}; + + const folders = {}; + const folderFor = ( parent, key, label ) => { + + const id = parent + '/' + key; + if ( ! folders[ id ] ) { folders[ id ] = ( parent === '' ? gui : folders[ parent ] ).addFolder( label ); folders[ id ].close(); } + return folders[ id ]; + + }; + + // One control bound to a uniform node's `.value`. + const addControl = ( folder, node, type, name, value, label, range ) => { + + if ( type === 'vec3f' && /color|scatter/.test( name ) ) { + + const proxy = { c: Array.isArray( value ) ? value.slice() : [ value.x, value.y, value.z ] }; + folder.addColor( proxy, 'c' ).name( label ).onChange( ( v ) => node.value.fromArray( v ) ); + + } else if ( type === 'vec3f' ) { + + const sub = folder.addFolder( label ); sub.close(); + const proxy = Array.isArray( value ) ? { x: value[ 0 ], y: value[ 1 ], z: value[ 2 ] } : { x: value.x, y: value.y, z: value.z }; + const lo = range ? range[ 0 ] : 0, hi = range ? range[ 1 ] : 2; + for ( const k of [ 'x', 'y', 'z' ] ) + sub.add( proxy, k, lo, hi ).onChange( () => node.value.set( proxy.x, proxy.y, proxy.z ) ); + + } else if ( type === 'bool' ) { + + const proxy = { v: !! value }; + folder.add( proxy, 'v' ).name( label ).onChange( ( v ) => { node.value = v; } ); + + } else if ( type === 'vec2f' ) { + + const sub = folder.addFolder( label ); sub.close(); + const proxy = Array.isArray( value ) ? { x: value[ 0 ], y: value[ 1 ] } : { x: value.x, y: value.y }; + const lo = range ? range[ 0 ] : 0, hi = range ? range[ 1 ] : 2; + for ( const k of [ 'x', 'y' ] ) + sub.add( proxy, k, lo, hi ).onChange( () => node.value.set( proxy.x, proxy.y ) ); + + } else if ( type === 'vec4f' ) { + + const sub = folder.addFolder( label ); sub.close(); + const proxy = Array.isArray( value ) + ? { x: value[ 0 ], y: value[ 1 ], z: value[ 2 ], w: value[ 3 ] } + : { x: value.x, y: value.y, z: value.z, w: value.w }; + const lo = range ? range[ 0 ] : 0, hi = range ? range[ 1 ] : 2; + for ( const k of [ 'x', 'y', 'z', 'w' ] ) + sub.add( proxy, k, lo, hi ).onChange( () => node.value.set( proxy.x, proxy.y, proxy.z, proxy.w ) ); + + } else if ( type === 'f32' || type === 'i32' || type === 'u32' ) { + + const [ lo, hi ] = range || scalarRange( name ); + const proxy = { v: Number( value ) || 0 }; + const ctrl = folder.add( proxy, 'v', lo, hi ).name( label ).onChange( ( v ) => { node.value = v; } ); + if ( type === 'i32' || type === 'u32' ) ctrl.step( 1 ); + + } + + }; + + // Surface inputs (semantic 'uniform'), grouped by closure category. + const surface = folderFor( '', 'surface', 'MaterialX surface' ); + surface.open(); + for ( const u of manifest.uniforms || [] ) { + + if ( u.semantic !== 'uniform' ) continue; + const node = args[ u.name ]; + if ( ! node || u.type === 'mat4x4f' ) continue; + const stripped = u.name.replace( /^[A-Za-z][A-Za-z0-9]*_/, '' ); // drop node prefix (e.g. 'ss_') + const cat = guiCategory( stripped ); + const folder = cat === 'other' ? surface : folderFor( '/surface', cat, cat.replace( /_/g, ' ' ) ); + addControl( folder, node, u.type, u.name, u.value, stripped, null ); + + } + + // Lighting + environment. + const lighting = folderFor( '', 'lighting', 'Lighting & environment' ); + for ( const u of manifest.uniforms || [] ) { + + const node = args[ u.name ]; + if ( ! node ) continue; + if ( u.semantic === 'light:direction' ) addControl( lighting, node, 'vec3f', 'dir_vec', u.value, 'light direction', [ - 1, 1 ] ); + else if ( u.semantic === 'light:color' ) addControl( lighting, node, 'vec3f', 'light_rgb', u.value, 'light color', [ 0, 10 ] ); + else if ( u.semantic === 'env:lightIntensity' ) addControl( lighting, node, 'f32', 'env_intensity', u.value, 'env intensity', [ 0, 3 ] ); + + } + + return gui; + +} diff --git a/javascript/MaterialXView/source/viewer.js b/javascript/MaterialXView/source/viewer.js index 5cfce0bcb3..2590043316 100644 --- a/javascript/MaterialXView/source/viewer.js +++ b/javascript/MaterialXView/source/viewer.js @@ -10,6 +10,8 @@ import { HDRLoader } from 'three/examples/jsm/loaders/HDRLoader.js'; import { prepareEnvTexture, getLightRotation, findLights, registerLights, getUniformValues } from './helper.js' import { Group } from 'three'; import GUI from 'lil-gui'; +import { createMxWgslMaterial, createMxWgslGUI, normalizeReflection } from './mxtsladapter.js'; +import { buildWgslManifest } from './wgslmanifest.js'; const ALL_GEOMETRY_SPECIFIER = "*"; const NO_GEOMETRY_SPECIFIER = ""; @@ -18,6 +20,96 @@ const DAG_PATH_SEPERATOR = "/"; // Logging toggle var logDetailedTime = false; +/** + * Configure genContext for WebGPU material generation. + * @returns {boolean} Whether the surface is transparent. + */ +function configureWebGPUGenContext(mx, gen, genContext, elem) +{ + // WebGPU: leave surface color linear; WebGPURenderer converts working space → sRGB for the canvas. + // WebGL RawShaderMaterial writes shader output directly, so that path keeps hwSrgbEncodeOutput=true. + genContext.getOptions().hwSrgbEncodeOutput = false; + const isTransparent = mx.isTransparentSurface(elem, gen.getTarget()); + genContext.getOptions().hwTransparency = isTransparent; + genContext.getOptions().shaderInterfaceType = mx.ShaderInterfaceType.SHADER_INTERFACE_COMPLETE; + return isTransparent; +} + +/** + * Generate WGSL pixel shader and manifest for a renderable element. + */ +function generateWebGPUShader(mx, gen, genContext, elem) +{ + const shader = gen.generate(elem.getNamePath(), elem, genContext); + const wgsl = shader.getSourceCode('pixel'); + // The in-repo WgslShaderGenerator does not emit a manifest; reconstruct it in JS from + // the generated WGSL text + the Shader's uniform ports (see wgslmanifest.js). + const manifest = buildWgslManifest(shader, wgsl); + return { shader, wgsl, manifest }; +} + +/** + * Build the first directional light payload for the TSL bridge. + */ +function buildDirectionalLight(lightData) +{ + if (!lightData || !lightData.length) return null; + const l = lightData[0]; + return { + direction: l.direction.clone(), + color: l.color.clone().multiplyScalar(l.intensity) + }; +} + +/** + * Build IBL environment payload for the TSL bridge. + */ +function buildEnvironment(radianceTexture, irradianceTexture, matrix, THREE) +{ + // HDR env maps are linear radiance data; disable Three.js sRGB decode on the TSL path + // (same rationale as buildTextureMap for material textures). + radianceTexture.colorSpace = THREE.NoColorSpace; + irradianceTexture.colorSpace = THREE.NoColorSpace; + const mips = Math.trunc(Math.log2(Math.max(radianceTexture.image.width, radianceTexture.image.height))) + 1; + return { + radiance: radianceTexture, + irradiance: irradianceTexture, + samples: 16, + mips, + intensity: 1.0, + matrix + }; +} + +/** + * Map manifest texture keys to loaded THREE.Texture objects. + * + * MaterialXView registers no color-management system, so the generated shader performs no + * sRGB->linear input transform, and the WebGL RawShaderMaterial samples textures raw (the + * stored sRGB bytes are used directly). A TSL `texture()` node, by contrast, decodes to + * linear working space when `texture.colorSpace` is sRGB. To stay pixel-identical with the + * WebGL reference we therefore force `NoColorSpace` so WebGPU samples raw as well. (Proper + * color management would mean registering a CMS so BOTH backends convert in-shader; that is + * a separate change that would alter the existing WebGL appearance.) + */ +function buildTextureMap(manifest, uniforms, THREE) +{ + // C++ reflection uses { bindings: [...] }; normalize before reading textures[]. + manifest = normalizeReflection(manifest); + const textures = {}; + for (const tex of (manifest.textures || [])) + { + if (tex.semantic !== 'texture') continue; + const u = uniforms[tex.key]; + if (u && u.value) + { + u.value.colorSpace = THREE.NoColorSpace; + textures[tex.key] = u.value; + } + } + return textures; +} + /* Scene management */ @@ -243,11 +335,18 @@ export class Scene updateObjectUniforms(child, material, camera) { if (!child || !material || !camera) return; + + // The WebGPU NodeMaterial has no `material.uniforms` block: its per-object world + // transforms come from TSL accessors (positionWorld / normalWorld / cameraPosition) + // and editor edits drive the TSL uniform nodes directly, so this path is a no-op for + // it. The guards below keep the WebGL (RawShaderMaterial) updates working unchanged. const uniforms = material.uniforms; if (!uniforms) return; - uniforms.u_worldMatrix.value = child.matrixWorld; - uniforms.u_viewProjectionMatrix.value = this.#_viewProjMat.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); + if (uniforms.u_worldMatrix) + uniforms.u_worldMatrix.value = child.matrixWorld; + if (uniforms.u_viewProjectionMatrix) + uniforms.u_viewProjectionMatrix.value = this.#_viewProjMat.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); if (uniforms.u_viewPosition) uniforms.u_viewPosition.value = camera.getWorldPosition(this.#_worldViewPos); @@ -900,6 +999,9 @@ export class Material // generateMaterial(matassign, viewer, searchPath, closeUI) { + if (viewer.getBackend() === 'webgpu') + return this.generateMaterialWebGPU(matassign, viewer, searchPath, closeUI); + var elem = matassign.getMaterial(); var startGenerateMat = performance.now(); @@ -985,6 +1087,68 @@ export class Material return newMaterial; } + // + // WebGPU material generation: emit WGSL with the upstream WgslShaderGenerator, reshape it + // into a TSL NodeMaterial via the bridge, and reuse the existing property Editor by + // exposing a RawShaderMaterial-style `uniforms` map plus a per-frame sync into the TSL + // nodes. Lighting reuses the rig's first directional light + the prepared IBL textures. + // + generateMaterialWebGPU(matassign, viewer, searchPath, closeUI) + { + const mx = viewer.getMx(); + const TSL = viewer.getTSL(); + const elem = matassign.getMaterial(); + const gen = viewer.getGenerator(); + const genContext = viewer.getGenContext(); + const textureLoader = new THREE.TextureLoader(); + + try + { + const isTransparent = configureWebGPUGenContext(mx, gen, genContext, elem); + const { shader, wgsl, manifest } = generateWebGPUShader(mx, gen, genContext, elem); + + const flipV = viewer.getScene().getFlipGeometryV(); + const uniforms = getUniformValues(shader.getStage('pixel'), textureLoader, searchPath, flipV); + const textures = buildTextureMap(manifest, uniforms, THREE); + + const environment = buildEnvironment( + viewer.getRadianceTexture(), + viewer.getIrradianceTexture(), + getLightRotation(), + THREE + ); + const light = buildDirectionalLight(viewer.getLightData()); + const lightData = viewer.getLightData(); + const numLights = viewer.getLights()?.length ?? 0; + + // Indexed geometry gets computed tangents in updateScene(); use them for anisotropic parity. + const material = createMxWgslMaterial({ + THREE, TSL, wgsl, manifest, light, lightData, numLights, environment, textures, + useGeometryTangent: true + }); + material.side = THREE.DoubleSide; + material.transparent = isTransparent; + material.name = elem.getName(); + + const gui = viewer.getEditor().getGUI(); + this.updateEditorWebGPU(matassign, material, manifest, gui, closeUI, viewer); + return material; + } + catch (error) + { + // WGSL generation or TSL assembly failed (e.g. an unsupported node). Surface it and + // return a visible magenta fallback so the viewer stays usable instead of crashing. + const name = elem ? elem.getName() : ''; + console.error(`WebGPU material generation failed for '${name}':`, + Number.isInteger(error) ? mx.getExceptionMessage(error) : error); + const fallback = new THREE.MeshBasicNodeMaterial(); + fallback.colorNode = TSL.vec3(1.0, 0.0, 1.0); + fallback.side = THREE.DoubleSide; + fallback.name = name; + return fallback; + } + } + clearSoloMaterialUI() { for (let i = 0; i < this._materials.length; ++i) @@ -1043,31 +1207,18 @@ export class Material // Update property editor for a given MaterialX element, it's shader, and // Three material // - updateEditor(matassign, shader, material, gui, closeUI, viewer) + setupMaterialFolder(matassign, elem, gui, closeUI, viewer) { - var elem = matassign.getMaterial(); - var materials = this._materials; - - const DEFAULT_MIN = 0; - const DEFAULT_MAX = 100; - - var startTime = performance.now(); - + const materials = this._materials; const elemPath = elem.getNamePath(); - - // Create and cache associated UI - var matUI = gui.addFolder(elemPath); + const matUI = gui.addFolder(elemPath); matassign.setMaterialUI(matUI); - let matTitle = matUI.domElement.getElementsByClassName('title')[0]; - // Add a icon to the title to allow for assigning the material to geometry - // Clicking on the icon will "solo" the material to the geometry. - // Clicking on the title will open/close the material folder. + const matTitle = matUI.domElement.getElementsByClassName('title')[0]; matTitle.innerHTML = "" + elem.getNamePath(); - let img = matTitle.getElementsByTagName('img')[0]; + const img = matTitle.getElementsByTagName('img')[0]; if (img) { - // Add event listener to icon to call updateSoloMaterial function img.addEventListener('click', function (event) { Material.updateSoloMaterial(viewer, elemPath, materials, event); @@ -1075,9 +1226,33 @@ export class Material } if (closeUI) - { matUI.close(); - } + + return matUI; + } + + // + // WebGPU property editor: manifest-driven TSL uniform nodes via createMxWgslGUI. + // + updateEditorWebGPU(matassign, material, manifest, gui, closeUI, viewer) + { + const elem = matassign.getMaterial(); + const matUI = this.setupMaterialFolder(matassign, elem, gui, closeUI, viewer); + createMxWgslGUI({ GUI, THREE, material, manifest, gui: matUI }); + } + + updateEditor(matassign, shader, material, gui, closeUI, viewer) + { + var elem = matassign.getMaterial(); + + const DEFAULT_MIN = 0; + const DEFAULT_MAX = 100; + + var startTime = performance.now(); + + var matUI = this.setupMaterialFolder(matassign, elem, gui, closeUI, viewer); + const elemPath = elem.getNamePath(); + const uniformBlocks = Object.values(shader.getStage('pixel').getUniformBlocks()); var uniformToUpdate; const ignoreList = ['u_envRadianceMips', 'u_envRadianceSamples', 'u_alphaThreshold']; @@ -1539,19 +1714,24 @@ export class Viewer // Create shader generator, generation context and "base" document which // contains the standard definition libraries and lighting elements. // - async initialize(mtlxIn, renderer, radianceTexture, irradianceTexture, lightRigXml) + async initialize(mtlxIn, renderer, radianceTexture, irradianceTexture, lightRigXml, opts = {}) { this.mx = mtlxIn; - - // Initialize base document - this.generator = this.mx.EsslShaderGenerator.create(); + this.backend = opts.backend || 'webgl'; + this.tsl = opts.TSL || null; + + // Initialize base document. The WebGPU backend uses the upstream WgslShaderGenerator + // (WGSL, consumed by the TSL bridge); WebGL uses the ESSL generator (GLSL). + this.generator = (this.backend === 'webgpu') + ? this.mx.WgslShaderGenerator.create() + : this.mx.EsslShaderGenerator.create(); this.genContext = new this.mx.GenContext(this.generator); this.document = this.mx.createDocument(); this.stdlib = this.mx.loadStandardLibraries(this.genContext); this.document.setDataLibrary(this.stdlib); - this.initializeLighting(renderer, radianceTexture, irradianceTexture, lightRigXml); + await this.initializeLighting(renderer, radianceTexture, irradianceTexture, lightRigXml); radianceTexture.mapping = THREE.EquirectangularReflectionMapping; this.getScene().setBackgroundTexture(radianceTexture); @@ -1626,6 +1806,16 @@ export class Viewer return this.mx; } + getBackend() + { + return this.backend; + } + + getTSL() + { + return this.tsl; + } + getGenerator() { return this.generator; diff --git a/javascript/MaterialXView/source/wgslmanifest.js b/javascript/MaterialXView/source/wgslmanifest.js new file mode 100644 index 0000000000..055cbaf924 --- /dev/null +++ b/javascript/MaterialXView/source/wgslmanifest.js @@ -0,0 +1,218 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +/** + * WGSL reflection manifest builder. + * + * The in-repo `WgslShaderGenerator` emits WGSL but does not emit the JSON reflection + * manifest that the TSL bridge (`mxtsladapter.js`) consumes. Rather than reintroduce + * manifest emission into the C++ generator, we reconstruct the manifest here in JS from + * two sources that are already available after `generate()`: + * + * 1. The generated WGSL text — supplies the `@group/@binding` layout, the final WGSL + * type spellings, the entry-function names, and the vertex-input struct (varyings). + * 2. The `Shader` object — supplies what is NOT present in the WGSL text: each uniform's + * default value (`ShaderPort.getValue()`) and the public-vs-host role (the uniform + * block name: `PublicUniforms` => editable "uniform", everything else => "host"). + * + * The output matches the "bindings" reflection shape accepted by + * `normalizeReflection()` in mxtsladapter.js, so no adapter changes are required. + */ + +// Uniform block names emitted by HwShaderGenerator (see source/MaterialXGenHw/HwConstants.cpp). +// The role split mirrors the original C++ manifest: PublicUniforms members are the +// user-editable surface inputs; all other blocks are engine/host-set. +const PUBLIC_UNIFORMS = 'PublicUniforms'; +const LIGHT_DATA = 'LightData'; + +// Entry-function names emitted by WgslShaderGenerator::emitPixelStage / emitVertexStage +// (see source/MaterialXGenWgsl/WgslShaderGenerator.cpp). The adapter is entry-name +// agnostic (it reads manifest.entry), so emitting the in-repo names is sufficient. +const PIXEL_ENTRY = 'fragmentMain'; +const VERTEX_ENTRY = 'vertexMain'; + +/** + * Format a MaterialX port value as a JS scalar / array / boolean. + * Mirrors the C++ `valueToJson` in WgslShaderGenerator.cpp so the produced object graph + * is identical to what `JSON.parse(getWgslGeneratedManifest(...))` used to yield. + * + * @param {Object} port - A bound mx.ShaderPort, or null. + * @return {(number|boolean|number[]|null)} The default value, or null when unset. + */ +function portValueToJson( port ) { + + const value = port && port.getValue && port.getValue(); + if ( ! value ) return null; + + const s = value.getValueString(); + if ( s.indexOf( ',' ) !== - 1 ) { + + // Vector / color / matrix: comma-separated numbers. + return s.split( ',' ) + .map( ( item ) => item.trim() ) + .filter( ( item ) => item.length > 0 ) + .map( ( item ) => parseFloat( item ) ); + + } + if ( s === 'true' ) return true; + if ( s === 'false' ) return false; + if ( s === '' ) return 0; + const n = Number( s ); + return Number.isNaN( n ) ? s : n; + +} + +/** + * Build a name -> { role, port } map from the pixel stage's uniform blocks. + * The block name determines the role; LightData members are not enumerated individually + * (the light-data array is exposed as a single `var` binding instead). + */ +function collectUniformPorts( shader ) { + + const map = new Map(); + let stage; + try { + + stage = shader.getStage( 'pixel' ); + + } catch ( e ) { + + return map; + + } + if ( ! stage ) return map; + + const blocks = stage.getUniformBlocks(); // JS object keyed by block name + for ( const blockName of Object.keys( blocks ) ) { + + if ( blockName === LIGHT_DATA ) continue; + const block = blocks[ blockName ]; + if ( ! block ) continue; + const role = blockName === PUBLIC_UNIFORMS ? 'uniform' : 'host'; + const count = block.size(); + for ( let i = 0; i < count; ++ i ) { + + const port = block.get( i ); + if ( ! port ) continue; + map.set( port.getVariable(), { role, port } ); + + } + + } + + return map; + +} + +// Matches `@group(N) @binding(M) var[] name: type;` lines emitted by +// WgslResourceBindingContext. `var` covers scalar/vector/matrix/array uniforms; +// bare `var` covers texture_2d and sampler bindings. +const BINDING_RE = /@group\(\s*(\d+)\s*\)\s*@binding\(\s*(\d+)\s*\)\s*var(?:<[^>]*>)?\s+([A-Za-z_]\w*)\s*:\s*([^;]+);/g; + +/** + * Parse the `@group/@binding` resource declarations out of the WGSL text into manifest + * bindings, attaching role + default value from the uniform-port map. + */ +function parseBindings( wgsl, portMap ) { + + const bindings = []; + BINDING_RE.lastIndex = 0; + for ( let m = BINDING_RE.exec( wgsl ); m !== null; m = BINDING_RE.exec( wgsl ) ) { + + const group = Number( m[ 1 ] ); + const binding = Number( m[ 2 ] ); + const name = m[ 3 ]; + const type = m[ 4 ].trim(); + + if ( name.endsWith( '_texture' ) || type.startsWith( 'texture_' ) ) { + + bindings.push( { stage: 'pixel', group, binding, name, type, role: 'texture', key: name.replace( /_texture$/, '' ) } ); + + } else if ( name.endsWith( '_sampler' ) || type === 'sampler' ) { + + bindings.push( { stage: 'pixel', group, binding, name, type, role: 'sampler', key: name.replace( /_sampler$/, '' ) } ); + + } else if ( type.startsWith( 'array<' ) ) { + + // Light-data array (struct array). The adapter re-parses the struct itself, + // so no per-member value is needed here. + bindings.push( { stage: 'pixel', group, binding, name, type, role: 'lightData' } ); + + } else { + + const info = portMap.get( name ); + const role = info ? info.role : 'host'; + const value = info ? portValueToJson( info.port ) : null; + bindings.push( { stage: 'pixel', group, binding, name, type, role, value } ); + + } + + } + + return bindings; + +} + +/** + * Parse the pixel entry signature `fn FragmentMain(: ) -> vec4f` plus the + * referenced `struct { name: type, ... }` into a single entryParam carrying the + * varying members. normalizeReflection() flattens this into the varying input list. + */ +function parseEntryParams( wgsl ) { + + const sig = new RegExp( `\\bfn\\s+${ PIXEL_ENTRY }\\s*\\(\\s*([A-Za-z_]\\w*)\\s*:\\s*([A-Za-z_]\\w*)\\s*\\)` ).exec( wgsl ); + if ( ! sig ) return []; + + const instName = sig[ 1 ]; + const structName = sig[ 2 ]; + + const structRe = new RegExp( `\\bstruct\\s+${ structName }\\s*\\{([^}]*)\\}` ); + const structMatch = structRe.exec( wgsl ); + const members = []; + if ( structMatch ) { + + for ( const rawLine of structMatch[ 1 ].split( '\n' ) ) { + + const line = rawLine.trim().replace( /,$/, '' ); + if ( ! line ) continue; + const colon = line.indexOf( ':' ); + if ( colon < 0 ) continue; + const decl = line.slice( 0, colon ); + const type = line.slice( colon + 1 ).trim(); + // Skip the @builtin(position) member: it is the fragment-position builtin, not a + // MaterialX surface varying — the library/material code never reads it, and binding + // it would mis-map to a varying semantic with a vec4/vec3 type mismatch. + if ( /@builtin\b/.test( decl ) ) continue; + // Drop @location(...) / @interpolate(flat) attribute prefixes; keep the bare + // member identifier (the generator emits attributes inline in the struct). + const name = decl.replace( /@\w+\s*\([^)]*\)/g, '' ).trim(); + if ( name && type ) members.push( { name, type } ); + + } + + } + + return [ { name: instName, type: structName, members } ]; + +} + +/** + * Build the WGSL reflection manifest (bindings format) from a generated Shader + its WGSL. + * + * @param {Object} shader - The mx.Shader returned by WgslShaderGenerator.generate(). + * @param {string} wgsl - The generated pixel-stage WGSL source. + * @return {Object} Manifest consumable by normalizeReflection() / convertToTslPortable(). + */ +export function buildWgslManifest( shader, wgsl ) { + + const portMap = collectUniformPorts( shader ); + return { + entry: { vertex: VERTEX_ENTRY, pixel: PIXEL_ENTRY }, + output: 'vec4f', + entryParams: parseEntryParams( wgsl ), + bindings: parseBindings( wgsl, portMap ) + }; + +} diff --git a/javascript/MaterialXView/webpack.config.js b/javascript/MaterialXView/webpack.config.js index b3f0e33f79..b00c0aef53 100644 --- a/javascript/MaterialXView/webpack.config.js +++ b/javascript/MaterialXView/webpack.config.js @@ -1,5 +1,6 @@ const path = require('path'); const fs = require('fs'); +const webpack = require('webpack'); const CopyPlugin = require("copy-webpack-plugin"); const HtmlWebpackPlugin = require('html-webpack-plugin') @@ -11,9 +12,9 @@ function processMaterialPath(materialPath, baseURL) { const dirent = fs.readdirSync(materialPath).filter( function (file) { if (file.lastIndexOf(".mtlx") > -1) return file; } ); - return dirent.map((fileName) => ({ - name: fileName, - value: `${baseURL}/${fileName}` + return dirent.map((fileName) => ({ + name: fileName, + value: `${baseURL}/${fileName}` })); } @@ -32,47 +33,58 @@ dirent = fs.readdirSync(geometryFiles).filter( let geometry = dirent .map((fileName) => ({ name: fileName, value: `${geometryFilesURL}/${fileName}` })); -module.exports = { - entry: './source/index.js', - output: { - filename: 'main.js', - path: path.resolve(__dirname, 'dist') - }, - mode: "development", - plugins: [ - new HtmlWebpackPlugin({ - templateParameters: { - materials, - geometry - }, - template: 'index.ejs' - }), - new CopyPlugin({ - patterns: [ - { - context: "../../resources/Images", - from: "*.*", - to: "Images", - }, - { - context: "../../resources/Geometry/", - from: "*.glb", - to: "Geometry", - }, - { from: "./public", to: 'public' }, - { context: "../../resources/Lights", from: "*.*", to: "Lights" }, - { context: "../../resources/Lights/irradiance", from: "*.*", to: "Lights/irradiance" }, - // Dynamically generate material copy patterns from configuration - ...materialConfig.materials.map(materialType => ({ - from: materialType.path, - to: materialType.baseURL - })), - { from: "../build/bin/JsMaterialXCore.wasm" }, - { from: "../build/bin/JsMaterialXCore.js" }, - { from: "../build/bin/JsMaterialXGenShader.wasm" }, - { from: "../build/bin/JsMaterialXGenShader.js" }, - { from: "../build/bin/JsMaterialXGenShader.data" }, - ], - }), - ] -}; +// Shared asset copy (WASM, libraries, textures, materials, geometry, lights). Applied to +// both backend configs so index-webgpu.html deploys standalone with all assets. +const copyPlugin = new CopyPlugin({ + patterns: [ + { context: "../../resources/Images", from: "*.*", to: "Images" }, + { context: "../../resources/Geometry/", from: "*.glb", to: "Geometry" }, + { from: "./public", to: 'public' }, + { context: "../../resources/Lights", from: "*.*", to: "Lights" }, + { context: "../../resources/Lights/irradiance", from: "*.*", to: "Lights/irradiance" }, + ...materialConfig.materials.map(materialType => ({ + from: materialType.path, + to: materialType.baseURL + })), + { from: "../build/bin/JsMaterialXCore.wasm" }, + { from: "../build/bin/JsMaterialXCore.js" }, + { from: "../build/bin/JsMaterialXGenShader.wasm" }, + { from: "../build/bin/JsMaterialXGenShader.js" }, + { from: "../build/bin/JsMaterialXGenShader.data" }, + ], +}); + +// Build one config per rendering backend. Both compile the SAME source; a `__BACKEND__` +// define + a per-backend `three` alias select the WebGL (classic THREE.WebGLRenderer + +// RawShaderMaterial / ESSL) or WebGPU (WebGPURenderer + NodeMaterial / WGSL) path. The two +// HTML pages let a toggle switch backends by navigation (avoids loading both three builds). +function makeConfig(backend) { + const isWebGPU = backend === 'webgpu'; + return { + name: backend, + entry: './source/index.js', + output: { + filename: isWebGPU ? 'main.webgpu.js' : 'main.js', + path: path.resolve(__dirname, 'dist'), + }, + mode: "development", + // Each bundle references only its backend's renderer at runtime, but both code paths + // exist in the shared source, so the unused one's renderer isn't in this build's three + // export set. That's expected — silence the "export not found in 'three'" warning. + ignoreWarnings: [ (w) => /was not found in 'three'/.test((w && w.message) || '') ], + // For WebGPU, resolve the bare `three` specifier to the WebGPU build (exact match + // via `three$`, so `three/tsl` and `three/webgpu` still resolve normally). + resolve: isWebGPU ? { alias: { 'three$': require.resolve('three/webgpu') } } : {}, + plugins: [ + new webpack.DefinePlugin({ __BACKEND__: JSON.stringify(backend) }), + new HtmlWebpackPlugin({ + filename: isWebGPU ? 'index-webgpu.html' : 'index.html', + template: 'index.ejs', + templateParameters: { materials, geometry, backend }, + }), + copyPlugin, + ], + }; +} + +module.exports = [makeConfig('webgl'), makeConfig('webgpu')]; diff --git a/libraries/CMakeLists.txt b/libraries/CMakeLists.txt index 432df18bea..cf5d6b4c7b 100644 --- a/libraries/CMakeLists.txt +++ b/libraries/CMakeLists.txt @@ -14,7 +14,8 @@ if(MATERIALX_BUILD_DATA_LIBRARY) *.osl *.h *.metal - *.slang) + *.slang + *.wgsl) foreach(SOURCE_FILE IN LISTS MATERIALX_DATA_LIBRARY_SOURCE_FILES) set(SOURCE_FILEPATH ${CMAKE_CURRENT_SOURCE_DIR}/${SOURCE_FILE}) diff --git a/libraries/lights/genwgsl/lights_genwgsl_impl.mtlx b/libraries/lights/genwgsl/lights_genwgsl_impl.mtlx new file mode 100644 index 0000000000..2e45ee9ccd --- /dev/null +++ b/libraries/lights/genwgsl/lights_genwgsl_impl.mtlx @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + diff --git a/libraries/lights/genwgsl/mx_directional_light.wgsl b/libraries/lights/genwgsl/mx_directional_light.wgsl new file mode 100644 index 0000000000..e4e24e760d --- /dev/null +++ b/libraries/lights/genwgsl/mx_directional_light.wgsl @@ -0,0 +1,4 @@ +fn mx_directional_light(light: LightData, position: vec3f, result: ptr) { + (*result).direction = -light.direction; + (*result).intensity = light.color * light.intensity; +} diff --git a/libraries/lights/genwgsl/mx_point_light.wgsl b/libraries/lights/genwgsl/mx_point_light.wgsl new file mode 100644 index 0000000000..e9a5e4f730 --- /dev/null +++ b/libraries/lights/genwgsl/mx_point_light.wgsl @@ -0,0 +1,7 @@ +fn mx_point_light(light: LightData, position: vec3f, result: ptr) { + (*result).direction = light.position - position; + let distance = length((*result).direction) + M_FLOAT_EPS; + let attenuation = pow(distance + 1.0, light.decay_rate + M_FLOAT_EPS); + (*result).intensity = light.color * light.intensity / attenuation; + (*result).direction = (*result).direction / distance; +} diff --git a/libraries/lights/genwgsl/mx_spot_light.wgsl b/libraries/lights/genwgsl/mx_spot_light.wgsl new file mode 100644 index 0000000000..4cdaac19bd --- /dev/null +++ b/libraries/lights/genwgsl/mx_spot_light.wgsl @@ -0,0 +1,12 @@ +fn mx_spot_light(light: LightData, position: vec3f, result: ptr) { + (*result).direction = light.position - position; + let distance = length((*result).direction) + M_FLOAT_EPS; + let attenuation = pow(distance + 1.0, light.decay_rate + M_FLOAT_EPS); + (*result).intensity = light.color * light.intensity / attenuation; + (*result).direction = (*result).direction / distance; + let low = min(light.inner_angle, light.outer_angle); + let high = light.inner_angle; + let cosDir = dot((*result).direction, -light.direction); + let spotAttenuation = smoothstep(low, high, cosDir); + (*result).intensity = (*result).intensity * spotAttenuation; +} diff --git a/libraries/pbrlib/genglsl/lib/mx_microfacet.glsl b/libraries/pbrlib/genglsl/lib/mx_microfacet.glsl index 3f50799513..a0a7106509 100644 --- a/libraries/pbrlib/genglsl/lib/mx_microfacet.glsl +++ b/libraries/pbrlib/genglsl/lib/mx_microfacet.glsl @@ -107,9 +107,11 @@ float mx_uniform_hemisphere_PDF() // Construct an orthonormal basis from a unit vector. // https://graphics.pixar.com/library/OrthonormalB/paper.pdf +// Branch at N.z = -1 instead of N.z = 0: a branch at the equator places a tangent-frame +// discontinuity on Z-up spheres, causing sawtooth artifacts on glossy/metallic materials. mat3 mx_orthonormal_basis(vec3 N) { - float sign = (N.z < 0.0) ? -1.0 : 1.0; + float sign = (N.z < -0.9999999) ? -1.0 : 1.0; float a = -1.0 / (sign + N.z); float b = N.x * N.y * a; vec3 X = vec3(1.0 + sign * N.x * N.x * a, sign * b, -sign * N.x); diff --git a/libraries/pbrlib/genglsl/lib/mx_microfacet_specular.glsl b/libraries/pbrlib/genglsl/lib/mx_microfacet_specular.glsl index d706e11f6d..b239c78ac1 100644 --- a/libraries/pbrlib/genglsl/lib/mx_microfacet_specular.glsl +++ b/libraries/pbrlib/genglsl/lib/mx_microfacet_specular.glsl @@ -22,8 +22,8 @@ struct FresnelData float exponent; // Thin film - float tf_thickness; - float tf_ior; + float thinfilm_thickness; + float thinfilm_ior; // Refraction bool refraction; @@ -306,7 +306,7 @@ vec3 mx_fresnel_airy(float cosTheta, FresnelData fd) // Assume vacuum on the outside float eta1 = 1.0; - float eta2 = max(fd.tf_ior, eta1); + float eta2 = max(fd.thinfilm_ior, eta1); vec3 eta3 = (fd.model == FRESNEL_MODEL_SCHLICK) ? mx_f0_to_ior(fd.F0) : fd.ior; vec3 kappa3 = (fd.model == FRESNEL_MODEL_SCHLICK) ? vec3(0.0) : fd.extinction; float cosThetaT = sqrt(1.0 - (1.0 - mx_square(cosTheta)) * mx_square(eta1 / eta2)); @@ -356,7 +356,7 @@ vec3 mx_fresnel_airy(float cosTheta, FresnelData fd) vec3 Cm, Sm; // Optical path difference - float distMeters = fd.tf_thickness * 1.0e-9; + float distMeters = fd.thinfilm_thickness * 1.0e-9; float opd = 2.0 * eta2 * cosThetaT * distMeters; // Iridescence term using spectral antialiasing for Parallel polarization @@ -398,53 +398,53 @@ vec3 mx_fresnel_airy(float cosTheta, FresnelData fd) return I; } -FresnelData mx_init_fresnel_dielectric(float ior, float tf_thickness, float tf_ior) +FresnelData mx_init_fresnel_dielectric(float ior, float thinfilm_thickness, float thinfilm_ior) { FresnelData fd; fd.model = FRESNEL_MODEL_DIELECTRIC; - fd.airy = tf_thickness > 0.0; + fd.airy = thinfilm_thickness > 0.0; fd.ior = vec3(ior); fd.extinction = vec3(0.0); fd.F0 = vec3(0.0); fd.F82 = vec3(0.0); fd.F90 = vec3(0.0); fd.exponent = 0.0; - fd.tf_thickness = tf_thickness; - fd.tf_ior = tf_ior; + fd.thinfilm_thickness = thinfilm_thickness; + fd.thinfilm_ior = thinfilm_ior; fd.refraction = false; return fd; } -FresnelData mx_init_fresnel_conductor(vec3 ior, vec3 extinction, float tf_thickness, float tf_ior) +FresnelData mx_init_fresnel_conductor(vec3 ior, vec3 extinction, float thinfilm_thickness, float thinfilm_ior) { FresnelData fd; fd.model = FRESNEL_MODEL_CONDUCTOR; - fd.airy = tf_thickness > 0.0; + fd.airy = thinfilm_thickness > 0.0; fd.ior = ior; fd.extinction = extinction; fd.F0 = vec3(0.0); fd.F82 = vec3(0.0); fd.F90 = vec3(0.0); fd.exponent = 0.0; - fd.tf_thickness = tf_thickness; - fd.tf_ior = tf_ior; + fd.thinfilm_thickness = thinfilm_thickness; + fd.thinfilm_ior = thinfilm_ior; fd.refraction = false; return fd; } -FresnelData mx_init_fresnel_schlick(vec3 F0, vec3 F82, vec3 F90, float exponent, float tf_thickness, float tf_ior) +FresnelData mx_init_fresnel_schlick(vec3 F0, vec3 F82, vec3 F90, float exponent, float thinfilm_thickness, float thinfilm_ior) { FresnelData fd; fd.model = FRESNEL_MODEL_SCHLICK; - fd.airy = tf_thickness > 0.0; + fd.airy = thinfilm_thickness > 0.0; fd.ior = vec3(0.0); fd.extinction = vec3(0.0); fd.F0 = F0; fd.F82 = F82; fd.F90 = F90; fd.exponent = exponent; - fd.tf_thickness = tf_thickness; - fd.tf_ior = tf_ior; + fd.thinfilm_thickness = thinfilm_thickness; + fd.thinfilm_ior = thinfilm_ior; fd.refraction = false; return fd; } diff --git a/libraries/pbrlib/genglsl/mx_chiang_hair_bsdf.glsl b/libraries/pbrlib/genglsl/mx_chiang_hair_bsdf.glsl index 31199d82ed..a0412d462c 100644 --- a/libraries/pbrlib/genglsl/mx_chiang_hair_bsdf.glsl +++ b/libraries/pbrlib/genglsl/mx_chiang_hair_bsdf.glsl @@ -145,7 +145,7 @@ float mx_hair_azimuthal_scattering( // Np return float(0.5 / M_PI); float dphi = phi - mx_hair_phi(p, gammaO, gammaT); - if (isinf(dphi)) + if (mx_isinf(dphi)) return float(0.5 / M_PI); while (dphi > M_PI) dphi -= (2.0 * M_PI); diff --git a/libraries/pbrlib/genwgsl/lib/mx_environment_fis.wgsl b/libraries/pbrlib/genwgsl/lib/mx_environment_fis.wgsl new file mode 100644 index 0000000000..7e062e2bec --- /dev/null +++ b/libraries/pbrlib/genwgsl/lib/mx_environment_fis.wgsl @@ -0,0 +1,72 @@ +#include "mx_microfacet_specular.wgsl" +// This shader cannot be pre-transpiled without generator specific binding information. + +fn mx_environment_radiance(N: vec3f, V_in: vec3f, X_in: vec3f, alpha: vec2f, distribution: i32, fd: FresnelData) -> vec3f +{ + // Generate tangent frame. + let X = normalize(X_in - dot(X_in, N) * N); + let Y = cross(N, X); + let tangentToWorld = mat3x3f(X, Y, N); + + // Transform the view vector to tangent space. + let V = vec3f(dot(V_in, X), dot(V_in, Y), dot(V_in, N)); + + // Compute derived properties. + let NdotV = clamp(V.z, M_FLOAT_EPS, 1.0); + let avgAlpha = mx_average_alpha(alpha); + let G1V = mx_ggx_smith_G1(NdotV, avgAlpha); + + // Integrate outgoing radiance using filtered importance sampling. + // http://cgg.mff.cuni.cz/~jaroslav/papers/2008-egsr-fis/2008-egsr-fis-final-embedded.pdf + var radiance = vec3f(0.0); + let envRadianceSamples: i32 = $envRadianceSamples; + for (var i: i32 = 0; i < envRadianceSamples; i++) + { + let Xi = mx_spherical_fibonacci(i, envRadianceSamples); + + // Compute the half vector and incoming light direction. + let H = mx_ggx_importance_sample_VNDF(Xi, V, alpha); + // For surface transmission, follow the refracted ray through a solid sphere; otherwise reflect. + let L = select(-reflect(V, H), mx_refraction_solid_sphere(-V, H, fd.ior.x), fd.refraction); + // Compute dot products for this sample. + let NdotL = clamp(L.z, M_FLOAT_EPS, 1.0); + let VdotH = clamp(dot(V, H), M_FLOAT_EPS, 1.0); + + // Sample the environment light from the given direction. + let Lw = tangentToWorld * L; + let pdf = mx_ggx_NDF(H, alpha) * G1V / (4.0 * NdotV); + let lod = mx_latlong_compute_lod(Lw, pdf, f32($envRadianceMips - 1), envRadianceSamples); + let sampleColor = mx_latlong_map_lookup(Lw, $envMatrix, lod, $envRadiance, $envRadianceSampler); + + // Compute the Fresnel term. + let F = mx_compute_fresnel(VdotH, fd); + + // Compute the geometric term. + let G = mx_ggx_smith_G2(NdotL, NdotV, avgAlpha); + + // The combined FG term simplifies to inverted Fresnel for refraction. + let FG = select(F * G, vec3f(1.0) - F, fd.refraction); + + // Add the radiance contribution of this sample. + // From https://cdn2.unrealengine.com/Resources/files/2013SiggraphPresentationsNotes-26915738.pdf + // incidentLight = sampleColor * NdotL + // microfacetSpecular = D * F * G / (4 * NdotL * NdotV) + // pdf = D * G1V / (4 * NdotV); + // radiance = incidentLight * microfacetSpecular / pdf + radiance += sampleColor * FG; + } + + // Apply the global component of the geometric term and normalize. + radiance /= G1V * f32(envRadianceSamples); + + // Return the final radiance. + if ($envRadianceSamples == 0) { + return vec3f(0.0); + } + return radiance * $envLightIntensity; +} + +fn mx_environment_irradiance(N: vec3f) -> vec3f { + let Li = mx_latlong_map_lookup(N, $envMatrix, 0.0, $envIrradiance, $envIrradianceSampler); + return Li * $envLightIntensity; +} diff --git a/libraries/pbrlib/genwgsl/lib/mx_environment_none.wgsl b/libraries/pbrlib/genwgsl/lib/mx_environment_none.wgsl new file mode 100644 index 0000000000..27b07650c9 --- /dev/null +++ b/libraries/pbrlib/genwgsl/lib/mx_environment_none.wgsl @@ -0,0 +1,12 @@ +// Environment lighting with no IBL contribution. +// WGSL equivalent of genglsl/lib/mx_environment_none.glsl. + +fn mx_environment_radiance(N: vec3f, V: vec3f, X: vec3f, roughness: vec2f, distribution: i32, fd: FresnelData) -> vec3f +{ + return vec3f(0.0); +} + +fn mx_environment_irradiance(N: vec3f) -> vec3f +{ + return vec3f(0.0); +} diff --git a/libraries/pbrlib/genwgsl/lib/mx_environment_prefilter.wgsl b/libraries/pbrlib/genwgsl/lib/mx_environment_prefilter.wgsl new file mode 100644 index 0000000000..e87f9e1885 --- /dev/null +++ b/libraries/pbrlib/genwgsl/lib/mx_environment_prefilter.wgsl @@ -0,0 +1,30 @@ +#include "mx_microfacet_specular.wgsl" +// This shader cannot be pre-transpiled without generator specific binding information. + +// Return the mip level associated with the given alpha in a prefiltered environment. +fn mx_latlong_alpha_to_lod(alpha: f32) -> f32 +{ + let lodBias = select(0.5 * alpha + 0.375, sqrt(alpha), alpha < 0.25); + return lodBias * f32($envRadianceMips - 1); +} + +fn mx_environment_radiance(N_in: vec3f, V: vec3f, X: vec3f, alpha: vec2f, distribution: i32, fd: FresnelData) -> vec3f +{ + let N = mx_forward_facing_normal(N_in, V); + let L = select(-reflect(V, N), mx_refraction_solid_sphere(-V, N, fd.ior.x), fd.refraction); + + let NdotV = clamp(dot(N, V), M_FLOAT_EPS, 1.0); + + let avgAlpha = mx_average_alpha(alpha); + var FG = mx_ggx_dir_albedo_fresnel(NdotV, avgAlpha, fd); + FG = select(FG, vec3f(1.0) - FG, fd.refraction); + + let Li = mx_latlong_map_lookup(L, $envMatrix, mx_latlong_alpha_to_lod(avgAlpha), $envRadiance, $envRadianceSampler); + return Li * FG * $envLightIntensity; +} + +fn mx_environment_irradiance(N: vec3f) -> vec3f +{ + let Li = mx_latlong_map_lookup(N, $envMatrix, 0.0, $envIrradiance, $envIrradianceSampler); + return Li * $envLightIntensity; +} diff --git a/libraries/pbrlib/genwgsl/lib/mx_generate_albedo_table.wgsl b/libraries/pbrlib/genwgsl/lib/mx_generate_albedo_table.wgsl new file mode 100644 index 0000000000..b661b52e32 --- /dev/null +++ b/libraries/pbrlib/genwgsl/lib/mx_generate_albedo_table.wgsl @@ -0,0 +1,11 @@ +#include "mx_microfacet_sheen.wgsl" +#include "mx_microfacet_specular.wgsl" +// This shader cannot be pre-transpiled without generator specific binding information. + +fn mx_generate_dir_albedo_table(fragCoord: vec2f, albedoTableSize: f32) -> vec3f +{ + let uv = fragCoord / albedoTableSize; + let ggxDirAlbedo = mx_ggx_dir_albedo(uv.x, uv.y, vec3f(1.0, 0.0, 0.0), vec3f(0.0, 1.0, 0.0)).xy; + let sheenDirAlbedo = mx_imageworks_sheen_dir_albedo(uv.x, uv.y); + return vec3f(ggxDirAlbedo.x, ggxDirAlbedo.y, sheenDirAlbedo); +} diff --git a/libraries/pbrlib/genwgsl/lib/mx_generate_prefilter_env.wgsl b/libraries/pbrlib/genwgsl/lib/mx_generate_prefilter_env.wgsl new file mode 100644 index 0000000000..a0c826efd6 --- /dev/null +++ b/libraries/pbrlib/genwgsl/lib/mx_generate_prefilter_env.wgsl @@ -0,0 +1,67 @@ +#include "mx_microfacet_specular.wgsl" +// This shader cannot be pre-transpiled without generator specific binding information. + +// Return the alpha associated with the given mip level in a prefiltered environment. +fn mx_latlong_lod_to_alpha(lod: f32) -> f32 +{ + let lodBias = lod / f32($envRadianceMips - 1); + return select(2.0 * (lodBias - 0.375), mx_square_f32(lodBias), lodBias < 0.5); +} + +// The inverse of mx_latlong_projection. +fn mx_latlong_map_projection_inverse(uv: vec2f) -> vec3f +{ + let latitude = (uv.y - 0.5) * M_PI; + let longitude = (uv.x - 0.5) * M_PI * 2.0; + + let x = -cos(latitude) * sin(longitude); + let y = -sin(latitude); + let z = cos(latitude) * cos(longitude); + + return vec3f(x, y, z); +} + +// `fragCoord` is the pixel center (gl_FragCoord.xy equivalent); +// `envRadianceSize` is the radiancemap's base-mip size in texels (textureDimensions of the radiance map). +fn mx_generate_prefilter_env(fragCoord: vec2f, envRadianceSize: vec2f) -> vec3f +{ + // The tangent view vector is aligned with the normal. + let V = vec3f(0.0, 0.0, 1.0); + let NdotV = 1.0; + + // Compute derived properties. + let uv = fragCoord * pow(2.0, f32($envPrefilterMip)) / envRadianceSize; + let worldN = mx_latlong_map_projection_inverse(uv); + let tangentToWorld = mx_orthonormal_basis(worldN); + let alpha = mx_latlong_lod_to_alpha(f32($envPrefilterMip)); + let G1V = mx_ggx_smith_G1(NdotV, alpha); + + // Integrate the LD term for the given environment and alpha. + var radiance = vec3f(0.0); + var weight = 0.0; + let envRadianceSamples: i32 = 1024; + for (var i: i32 = 0; i < envRadianceSamples; i++) + { + let Xi = mx_spherical_fibonacci(i, envRadianceSamples); + + // Compute the half vector and incoming light direction. + let H = mx_ggx_importance_sample_VNDF(Xi, V, vec2f(alpha)); + let L = -V + 2.0 * H.z * H; + + // Compute the geometric term for this sample. + let NdotL = clamp(L.z, M_FLOAT_EPS, 1.0); + let G = mx_ggx_smith_G2(NdotL, NdotV, alpha); + + // Sample the environment light from the given direction. + let Lw = tangentToWorld * L; + let pdf = mx_ggx_VNDF_reflection_PDF(H, vec2f(alpha), G1V, NdotV); + let lod = mx_latlong_compute_lod(Lw, pdf, f32($envRadianceMips - 1), envRadianceSamples); + let sampleColor = mx_latlong_map_lookup(Lw, $envMatrix, lod, $envRadiance, $envRadianceSampler); + + // Add the radiance contribution of this sample. + radiance += G * sampleColor; + weight += G; + } + + return radiance / weight; +} diff --git a/libraries/pbrlib/genwgsl/lib/mx_shadow.wgsl b/libraries/pbrlib/genwgsl/lib/mx_shadow.wgsl new file mode 100644 index 0000000000..9cb645f72a --- /dev/null +++ b/libraries/pbrlib/genwgsl/lib/mx_shadow.wgsl @@ -0,0 +1,25 @@ +// This shader cannot be pre-transpiled since it needs glsl intrinsics . + +// Variance shadow mapping utilities. +// https://developer.nvidia.com/gpugems/gpugems3/part-ii-light-and-shadows/chapter-8-summed-area-variance-shadow-maps + +fn mx_variance_shadow_occlusion(moments: vec2f, fragmentDepth: f32) -> f32 +{ + let MIN_VARIANCE = 0.00001; + + // One-tailed inequality valid if fragmentDepth > moments.x. + var p: f32; + if (fragmentDepth <= moments.x) { + p = 1.0; + } else { + p = 0.0; + } + + // Compute variance. + let variance = max(moments.y - mx_square_f32(moments.x), MIN_VARIANCE); + + // Compute probabilistic upper bound. + let d = fragmentDepth - moments.x; + let pMax = variance / (variance + mx_square_f32(d)); + return max(p, pMax); +} diff --git a/libraries/pbrlib/genwgsl/lib/mx_shadow_platform.wgsl b/libraries/pbrlib/genwgsl/lib/mx_shadow_platform.wgsl new file mode 100644 index 0000000000..718c98168c --- /dev/null +++ b/libraries/pbrlib/genwgsl/lib/mx_shadow_platform.wgsl @@ -0,0 +1,9 @@ +// This shader cannot be pre-transpiled without generator specific binding information. + +fn mx_shadow_occlusion(shadow_matrix: mat4x4f, world_position: vec3f) -> f32 +{ + let shadowCoord4 = (shadow_matrix * vec4f(world_position, 1.0)); + let shadowCoord = shadowCoord4.xyz / shadowCoord4.w * 0.5 + 0.5; + let shadowMoments = textureSample(shadowMap, shadowMapSampler, shadowCoord.xy).xy; + return mx_variance_shadow_occlusion(shadowMoments, shadowCoord.z); +} diff --git a/libraries/pbrlib/genwgsl/pbrlib_genwgsl_impl.mtlx b/libraries/pbrlib/genwgsl/pbrlib_genwgsl_impl.mtlx new file mode 100644 index 0000000000..c6a725f841 --- /dev/null +++ b/libraries/pbrlib/genwgsl/pbrlib_genwgsl_impl.mtlx @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libraries/stdlib/genglsl/lib/mx_math.glsl b/libraries/stdlib/genglsl/lib/mx_math.glsl index 623fbcb39e..4537568aed 100644 --- a/libraries/stdlib/genglsl/lib/mx_math.glsl +++ b/libraries/stdlib/genglsl/lib/mx_math.glsl @@ -13,6 +13,8 @@ #define mx_radians radians #define mx_float_bits_to_int floatBitsToInt +bool mx_isinf(float v) { return isinf(v); } + vec2 mx_matrix_mul(vec2 v, mat2 m) { return v * m; } vec3 mx_matrix_mul(vec3 v, mat3 m) { return v * m; } vec4 mx_matrix_mul(vec4 v, mat4 m) { return v * m; } diff --git a/libraries/stdlib/genwgsl/lib/mx_flake.wgsl b/libraries/stdlib/genwgsl/lib/mx_flake.wgsl new file mode 100644 index 0000000000..5d06489f09 --- /dev/null +++ b/libraries/stdlib/genwgsl/lib/mx_flake.wgsl @@ -0,0 +1,116 @@ +#include "mx_noise.wgsl" + +// "Fast Random Rotation Matrices" by James Arvo, Graphics Gems3 P.117 +fn mx_rotate_flake(p: vec3f, i: vec3f) -> vec3f { + let theta = M_PI * 2.0 * i.x; + let phi = M_PI * 2.0 * i.y; + let z = i.z * 2.0; + + let r = sqrt(z); + let vx = sin(phi) * r; + let vy = cos(phi) * r; + let vz = sqrt(2.0 - z); + + let s_theta = sin(theta); + let c_theta = cos(theta); + let sx = vx * c_theta - vy * s_theta; + let sy = vx * s_theta + vy * c_theta; + + let m = mat3x3f( + vx * sx - c_theta, vx * sy - s_theta, vx * vz, + vy * sx + s_theta, vy * sy - c_theta, vy * vz, + vz * sx , vz * sy , 1.0 - z + ); + + return m * p; +} + +fn mx_flake_density_to_probability(x: f32) -> f32 { + let abcd = vec4f(-26.19771808, 26.39663835, 85.53857017, -102.35069432); + let ef = vec2f(-101.42634862, 118.45082288); + let xx = x * x; + + return (abcd.x * xx + abcd.y * x) / (abcd.z * xx * x + abcd.w * xx + ef.x * x + ef.y); +} + +fn mx_flake( + size: f32, + roughness: f32, + coverage: f32, + position: vec3f, + normal: vec3f, + tangent: vec3f, + bitangent: vec3f, + id: ptr, + rand_out: ptr, + presence: ptr, + flakenormal: ptr +) { + let probability = mx_flake_density_to_probability(clamp(coverage, 0.0, 1.0)); + let flake_diameter = 1.5 / sqrt(3.0); + + let P = position / vec3f(size); + let base_P = floor(P); + + var flake_priority: f32 = 0.0; + var flake_cell: vec3f = vec3f(0.0); + + for (var i: i32 = -1; i < 2; i = i + 1) { + for (var j: i32 = -1; j < 2; j = j + 1) { + for (var k: i32 = -1; k < 2; k = k + 1) { + let cell_pos = base_P + vec3f(f32(i), f32(j), f32(k)); + + let PP_pre = P - cell_pos - vec3f(0.5); + if (dot(PP_pre, PP_pre) >= flake_diameter * flake_diameter * 3.0) { + continue; + } + + if (mx_cell_noise_float_vec3(cell_pos) > probability) { + continue; + } + + let priority = mx_cell_noise_float_vec4(vec4f(cell_pos, 3.0)); + if (priority < flake_priority) { + continue; + } + + let rot = mx_cell_noise_vec3_vec3(cell_pos); + let PP = mx_rotate_flake(PP_pre, rot); + + if (abs(PP.x) <= flake_diameter && + abs(PP.y) <= flake_diameter && + abs(PP.z) <= flake_diameter) { + flake_priority = priority; + flake_cell = cell_pos; + } + } + } + } + + if (flake_priority <= 0.0) { + *id = 0; + *rand_out = 0.0; + *presence = 0.0; + *flakenormal = normal; + return; + } + + let flake_noise = mx_cell_noise_vec3_vec4(vec4f(flake_cell, 2.0)); + let xi0 = flake_noise.x; + let xi1 = flake_noise.y; + + *rand_out = flake_noise.z; + *id = i32(*rand_out * 2147483647.0); + *presence = flake_priority; + + let phi = M_PI * 2.0 * xi0; + let tan_theta = roughness * roughness * sqrt(xi1) / sqrt(1.0 - xi1); + let sin_theta = tan_theta / sqrt(1.0 + tan_theta * tan_theta); + let cos_theta = sqrt(1.0 - sin_theta * sin_theta); + + *flakenormal = normalize( + tangent * cos(phi) * sin_theta + + bitangent * sin(phi) * sin_theta + + normal * cos_theta + ); +} diff --git a/libraries/stdlib/genwgsl/lib/mx_geometry.wgsl b/libraries/stdlib/genwgsl/lib/mx_geometry.wgsl new file mode 100644 index 0000000000..be6ed0bfd6 --- /dev/null +++ b/libraries/stdlib/genwgsl/lib/mx_geometry.wgsl @@ -0,0 +1,36 @@ +// Normal blending and axis rotation utilities. +// Port of genglsl/lib/mx_geometry.glsl + +// Blend 3 normals by blending the gradients. +// Morten S. Mikkelsen, Surface Gradient–Based Bump Mapping Framework, JCGT vol. 9, no. 3, 2020 +// http://jcgt.org/published/0009/03/04/ +fn mx_normals_to_gradient(N: vec3f, Np: vec3f) -> vec3f { + let d = dot(N, Np); + return (d * N - Np) / max(M_FLOAT_EPS, abs(d)); +} + +fn mx_gradient_blend_3_normals(N: vec3f, N1: vec3f, N1_weight: f32, N2: vec3f, N2_weight: f32, N3: vec3f, N3_weight: f32) -> vec3f { + let w1 = clamp(N1_weight, 0.0, 1.0); + let w2 = clamp(N2_weight, 0.0, 1.0); + let w3 = clamp(N3_weight, 0.0, 1.0); + + let g1 = mx_normals_to_gradient(N, N1); + let g2 = mx_normals_to_gradient(N, N2); + let g3 = mx_normals_to_gradient(N, N3); + + let gg = w1 * g1 + w2 * g2 + w3 * g3; + return normalize(N - gg); +} + +// Rotation matrix around an arbitrary axis. +// (Placed here rather than mx_math.wgsl to match MaterialX's workaround for MSL builds.) +fn mx_axis_rotation_matrix(a: vec3f, r: f32) -> mat3x3f { + let s = sin(r); + let c = cos(r); + let omc = 1.0 - c; + return mat3x3f( + a.x * a.x * omc + c, a.x * a.y * omc - a.z * s, a.x * a.z * omc + a.y * s, + a.y * a.x * omc + a.z * s, a.y * a.y * omc + c, a.y * a.z * omc - a.x * s, + a.z * a.x * omc - a.y * s, a.z * a.y * omc + a.x * s, a.z * a.z * omc + c + ); +} diff --git a/libraries/stdlib/genwgsl/lib/mx_hextile.wgsl b/libraries/stdlib/genwgsl/lib/mx_hextile.wgsl new file mode 100644 index 0000000000..f6a7d98d2c --- /dev/null +++ b/libraries/stdlib/genwgsl/lib/mx_hextile.wgsl @@ -0,0 +1,145 @@ +// Hex-tiling coordinate generation utilities. +// +// Morten S. Mikkelsen, Practical Real-Time Hex-Tiling, JCGT vol. 11, no. 2, 2022 +// http://jcgt.org/published/0011/03/05/ + +// https://www.shadertoy.com/view/4djSRW +fn mx_hextile_hash(p: vec2f) -> vec2f { + var p3 = fract(vec3f(p.x, p.y, p.x) * vec3f(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, vec3f(p3.y, p3.z, p3.x) + 33.33); + return fract((vec2f(p3.x, p3.x) + vec2f(p3.y, p3.z)) * vec2f(p3.z, p3.y)); +} + +// Christophe Schlick. "Fast Alternatives to Perlin's Bias and Gain Functions". +// In Graphics Gems IV, Morgan Kaufmann, 1994, pages 401–403. +// https://dept-info.labri.fr/~schlick/DOC/gem2.html +fn mx_schlick_gain(x: f32, r: f32) -> f32 { + let rr = clamp(r, 0.001, 0.999); + let a = (1.0 / rr - 2.0) * (1.0 - 2.0 * x); + if (x < 0.5) { + return x / (a + 1.0); + } + return (a - x) / (a - 1.0); +} + +struct HextileData { + coords0: vec2f, + coords1: vec2f, + coords2: vec2f, + weights: vec3f, + rotations: vec3f, + ddx0: vec2f, + ddx1: vec2f, + ddx2: vec2f, + ddy0: vec2f, + ddy1: vec2f, + ddy2: vec2f, +}; + +// Helper to compute blend weights with optional falloff. +fn mx_hextile_compute_blend_weights(luminance_weights: vec3f, tile_weights: vec3f, falloff: f32) -> vec3f { + var w = luminance_weights * pow(tile_weights, vec3f(7.0)); + w /= (w.x + w.y + w.z); + + if (falloff != 0.5) { + w.x = mx_schlick_gain(w.x, falloff); + w.y = mx_schlick_gain(w.y, falloff); + w.z = mx_schlick_gain(w.z, falloff); + w /= (w.x + w.y + w.z); + } + return w; +} + +fn mx_hextile_coord( + coord: vec2f, + rotation: f32, + rotation_range: vec2f, + scale: f32, + scale_range: vec2f, + offset: f32, + offset_range: vec2f +) -> HextileData { + let sqrt3_2 = sqrt(3.0) * 2.0; + + // Scale coord to maintain the original fit. + let st = coord * sqrt3_2; + + // Skew input space into simplex triangle grid. + // (1, 0, -tan(30), 2*tan(30)) + let to_skewed = mat2x2f(1.0, 0.0, -0.57735027, 1.15470054); + let st_skewed = (to_skewed * st); + + // Barycentric weights. + let st_frac = fract(st_skewed); + var temp = vec3f(st_frac.x, st_frac.y, 0.0); + temp.z = 1.0 - temp.x - temp.y; + + let s = step(0.0, -temp.z); + let s2 = 2.0 * s - 1.0; + + let w1 = -temp.z * s2; + let w2 = s - temp.y * s2; + let w3 = s - temp.x * s2; + + // Vertex IDs. + let base_id = vec2(floor(st_skewed)); + let si = i32(s); + let id1 = base_id + vec2(si, si); + let id2 = base_id + vec2(si, 1 - si); + let id3 = base_id + vec2(1 - si, si); + + // Tile centers. + let inv_skewed = mat2x2f(1.0, 0.0, 0.5, 1.0 / 1.15470054); + let ctr1 = (inv_skewed * (vec2f(id1) / vec2f(sqrt3_2))); + let ctr2 = (inv_skewed * (vec2f(id2) / vec2f(sqrt3_2))); + let ctr3 = (inv_skewed * (vec2f(id3) / vec2f(sqrt3_2))); + + // Reuse hash for performance. + let seed_offset = vec2f(0.12345); + let rand1 = mx_hextile_hash(vec2f(id1) + seed_offset); + let rand2 = mx_hextile_hash(vec2f(id2) + seed_offset); + let rand3 = mx_hextile_hash(vec2f(id3) + seed_offset); + + // Randomized rotation. + let rr = radians(rotation_range); + let rand_x = vec3f(rand1.x, rand2.x, rand3.x); + let rotations = mix(vec3f(rr.x), vec3f(rr.y), rand_x * rotation); + let sin_r = sin(rotations); + let cos_r = cos(rotations); + let rm1 = mat2x2f(cos_r.x, -sin_r.x, sin_r.x, cos_r.x); + let rm2 = mat2x2f(cos_r.y, -sin_r.y, sin_r.y, cos_r.y); + let rm3 = mat2x2f(cos_r.z, -sin_r.z, sin_r.z, cos_r.z); + + // Randomized scale. + let rand_y = vec3f(rand1.y, rand2.y, rand3.y); + let scales = mix(vec3f(1.0), mix(vec3f(scale_range.x), vec3f(scale_range.y), rand_y), scale); + let scale1 = vec2f(scales.x); + let scale2 = vec2f(scales.y); + let scale3 = vec2f(scales.z); + + // Randomized offset. + let offset1 = mix(vec2f(offset_range.x), vec2f(offset_range.y), rand1 * offset); + let offset2 = mix(vec2f(offset_range.x), vec2f(offset_range.y), rand2 * offset); + let offset3 = mix(vec2f(offset_range.x), vec2f(offset_range.y), rand3 * offset); + + var tile_data: HextileData; + tile_data.weights = vec3f(w1, w2, w3); + tile_data.rotations = rotations; + + // Get tile-local coords. + tile_data.coords0 = (((coord - ctr1) * rm1) / scale1) + ctr1 + offset1; + tile_data.coords1 = (((coord - ctr2) * rm2) / scale2) + ctr2 + offset2; + tile_data.coords2 = (((coord - ctr3) * rm3) / scale3) + ctr3 + offset3; + + // Derivatives. + let ddx_coord = dpdx(coord); + let ddy_coord = dpdy(coord); + tile_data.ddx0 = (ddx_coord * rm1) / scale1; + tile_data.ddx1 = (ddx_coord * rm2) / scale2; + tile_data.ddx2 = (ddx_coord * rm3) / scale3; + tile_data.ddy0 = (ddy_coord * rm1) / scale1; + tile_data.ddy1 = (ddy_coord * rm2) / scale2; + tile_data.ddy2 = (ddy_coord * rm3) / scale3; + + return tile_data; +} diff --git a/libraries/stdlib/genwgsl/lib/mx_hsv.wgsl b/libraries/stdlib/genwgsl/lib/mx_hsv.wgsl new file mode 100644 index 0000000000..2f32986ab4 --- /dev/null +++ b/libraries/stdlib/genwgsl/lib/mx_hsv.wgsl @@ -0,0 +1,92 @@ +// Color transform functions (WGSL port of genglsl/lib/mx_hsv.glsl) +// +// These functions are modified versions of the color operators found in +// Open Shading Language: +// github.com/imageworks/OpenShadingLanguage/blob/master/src/liboslexec/opcolor.cpp +// +// It contains the subset of color operators needed to implement the MaterialX +// standard library. The modifications are conversions from C++ to GLSL to WGSL. +// +// Original copyright notice: +// ------------------------------------------------------------------------ +// Copyright (c) 2009-2010 Sony Pictures Imageworks Inc., et al. +// All Rights Reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// * Neither the name of Sony Pictures Imageworks nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// ------------------------------------------------------------------------ + +fn mx_hsvtorgb(hsv: vec3f) -> vec3f { + // Reference for this technique: Foley & van Dam + let h = hsv.x; + let s = hsv.y; + let v = hsv.z; + + if (s < 0.0001) { + return vec3f(v, v, v); + } + + let hue = 6.0 * (h - floor(h)); // expand to [0..6) + let hi = i32(trunc(hue)); + let f = hue - f32(hi); + let p = v * (1.0 - s); + let q = v * (1.0 - s * f); + let t = v * (1.0 - s * (1.0 - f)); + + if (hi == 0) { return vec3f(v, t, p); } + if (hi == 1) { return vec3f(q, v, p); } + if (hi == 2) { return vec3f(p, v, t); } + if (hi == 3) { return vec3f(p, q, v); } + if (hi == 4) { return vec3f(t, p, v); } + return vec3f(v, p, q); +} + +fn mx_rgbtohsv(c: vec3f) -> vec3f { + // See Foley & van Dam + let r = c.x; + let g = c.y; + let b = c.z; + let mincomp = min(r, min(g, b)); + let maxcomp = max(r, max(g, b)); + let delta = maxcomp - mincomp; // chroma + + let v = maxcomp; + var s: f32; + if (maxcomp > 0.0) { + s = delta / maxcomp; + } else { + s = 0.0; + } + + var h: f32; + if (s <= 0.0) { + h = 0.0; + } else { + if (r >= maxcomp) { h = (g - b) / delta; } + else if (g >= maxcomp) { h = 2.0 + (b - r) / delta; } + else { h = 4.0 + (r - g) / delta; } + h *= (1.0 / 6.0); + if (h < 0.0) { h += 1.0; } + } + return vec3f(h, s, v); +} diff --git a/libraries/stdlib/genwgsl/lib/mx_math.wgsl b/libraries/stdlib/genwgsl/lib/mx_math.wgsl new file mode 100644 index 0000000000..87af1a1961 --- /dev/null +++ b/libraries/stdlib/genwgsl/lib/mx_math.wgsl @@ -0,0 +1,78 @@ +// Matrix-by-vector multiply used by the hardware geometric nodes (normal/tangent/ +// bitangent/transform/view-direction). WGSL has no function overloading, and every +// emitter uses the mat4x4 * vec4 form, so a single definition suffices. +fn mx_matrix_mul(m: mat4x4f, v: vec4f) -> vec4f { + return m * v; +} + +// Type-suffixed forms of GLSL's overloaded mx_matrix_mul, used by the transpiled library nodes +// (transformmatrix, blackbody, ...). vector*matrix and matrix*vector follow GLSL's argument order. +fn mx_matrix_mul_vec2_mat2(v: vec2f, m: mat2x2f) -> vec2f { return v * m; } +fn mx_matrix_mul_vec3_mat3(v: vec3f, m: mat3x3f) -> vec3f { return v * m; } +fn mx_matrix_mul_vec4_mat4(v: vec4f, m: mat4x4f) -> vec4f { return v * m; } +fn mx_matrix_mul_mat2_vec2(m: mat2x2f, v: vec2f) -> vec2f { return m * v; } +fn mx_matrix_mul_mat3_vec3(m: mat3x3f, v: vec3f) -> vec3f { return m * v; } +fn mx_matrix_mul_mat4_vec4(m: mat4x4f, v: vec4f) -> vec4f { return m * v; } +fn mx_matrix_mul_mat2_mat2(a: mat2x2f, b: mat2x2f) -> mat2x2f { return a * b; } +fn mx_matrix_mul_mat3_mat3(a: mat3x3f, b: mat3x3f) -> mat3x3f { return a * b; } +fn mx_matrix_mul_mat4_mat4(a: mat4x4f, b: mat4x4f) -> mat4x4f { return a * b; } + +// Square functions + +fn mx_square_f32(x: f32) -> f32 { + return (x * x); +} + +fn mx_square_vec2(x: vec2f) -> vec2f { + return (x * x); +} + +fn mx_square_vec3(x: vec3f) -> vec3f { + return (x * x); +} + +fn mx_isinf(v: f32) -> bool { + // WGSL has no isInf. +/-inf is the only bit pattern with all exponent bits set and a zero + // mantissa; masking the sign bit matches both infinities, while NaN (nonzero mantissa) does + // not -- matching GLSL isinf(). A magnitude compare is avoided because the only correct + // threshold is exactly FLT_MAX, and that literal overflows f32 const-eval on some drivers. + return (bitcast(v) & 0x7fffffffu) == 0x7f800000u; +} + +// Modulo with GLSL mod() semantics: x - y * floor(x / y) +// WGSL '%' operator is remainder (fmod), not modulo, so we need explicit functions. + +fn mx_mod_f32(x: f32, y: f32) -> f32 { + return x - y * floor(x / y); +} + +fn mx_mod_vec2(x: vec2f, y: vec2f) -> vec2f { + return x - y * floor(x / y); +} + +fn mx_mod_vec2_f32(x: vec2f, y: f32) -> vec2f { + return x - vec2f(y) * floor(x / vec2f(y)); +} + +fn mx_mod_vec3(x: vec3f, y: vec3f) -> vec3f { + return x - y * floor(x / y); +} + +fn mx_mod_vec3_f32(x: vec3f, y: f32) -> vec3f { + return x - vec3f(y) * floor(x / vec3f(y)); +} + +fn mx_mod_vec4(x: vec4f, y: vec4f) -> vec4f { + return x - y * floor(x / y); +} + +fn mx_mod_vec4_f32(x: vec4f, y: f32) -> vec4f { + return x - vec4f(y) * floor(x / vec4f(y)); +} + +fn mx_srgb_encode(color: vec3f) -> vec3f { + let isAbove = (color > vec3(0.0031308f)); + let linSeg = (color * 12.92f); + let powSeg = ((1.055f * pow(max(color, vec3(0f)), vec3(0.41666666f))) - vec3(0.055f)); + return select(linSeg, powSeg, isAbove); +} diff --git a/libraries/stdlib/genwgsl/lib/mx_noise.wgsl b/libraries/stdlib/genwgsl/lib/mx_noise.wgsl new file mode 100644 index 0000000000..38a9b4c3c6 --- /dev/null +++ b/libraries/stdlib/genwgsl/lib/mx_noise.wgsl @@ -0,0 +1,716 @@ +// Noise Library (WGSL port of genglsl/lib/mx_noise.glsl) +// +// This library is a modified version of the noise library found in +// Open Shading Language: +// github.com/imageworks/OpenShadingLanguage/blob/master/src/include/OSL/oslnoise.h +// +// It contains the subset of noise types needed to implement the MaterialX +// standard library. The modifications are conversions from C++ to GLSL to WGSL. +// Produced results should be identical to the OSL noise functions. +// +// Original copyright notice: +// ------------------------------------------------------------------------ +// Copyright (c) 2009-2010 Sony Pictures Imageworks Inc., et al. +// All Rights Reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// * Neither the name of Sony Pictures Imageworks nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// ------------------------------------------------------------------------ + +fn mx_select(b: bool, t: f32, f: f32) -> f32 { + return select(f, t, b); +} + +fn mx_negate_if(val: f32, b: bool) -> f32 { + return select(val, -val, b); +} + +fn mx_floor(x: f32) -> i32 { + return i32(floor(x)); +} + +// Return mx_floor as well as the fractional remainder. +fn mx_floorfrac(x: f32, i: ptr) -> f32 { + *i = mx_floor(x); + return x - f32(*i); +} + +// --------------------------------------------------------------------------- +// Bilinear / trilinear interpolation +// --------------------------------------------------------------------------- + +fn mx_bilerp_f32(v0: f32, v1: f32, v2: f32, v3: f32, s: f32, t: f32) -> f32 { + let s1 = 1.0 - s; + return (1.0 - t) * (v0 * s1 + v1 * s) + t * (v2 * s1 + v3 * s); +} + +fn mx_bilerp_vec3(v0: vec3f, v1: vec3f, v2: vec3f, v3: vec3f, s: f32, t: f32) -> vec3f { + let s1 = 1.0 - s; + return (1.0 - t) * (v0 * s1 + v1 * s) + t * (v2 * s1 + v3 * s); +} + +fn mx_trilerp_f32(v0: f32, v1: f32, v2: f32, v3: f32, + v4: f32, v5: f32, v6: f32, v7: f32, + s: f32, t: f32, r: f32) -> f32 { + let s1 = 1.0 - s; + let t1 = 1.0 - t; + let r1 = 1.0 - r; + return r1 * (t1 * (v0 * s1 + v1 * s) + t * (v2 * s1 + v3 * s)) + + r * (t1 * (v4 * s1 + v5 * s) + t * (v6 * s1 + v7 * s)); +} + +fn mx_trilerp_vec3(v0: vec3f, v1: vec3f, v2: vec3f, v3: vec3f, + v4: vec3f, v5: vec3f, v6: vec3f, v7: vec3f, + s: f32, t: f32, r: f32) -> vec3f { + let s1 = 1.0 - s; + let t1 = 1.0 - t; + let r1 = 1.0 - r; + return r1 * (t1 * (v0 * s1 + v1 * s) + t * (v2 * s1 + v3 * s)) + + r * (t1 * (v4 * s1 + v5 * s) + t * (v6 * s1 + v7 * s)); +} + +// --------------------------------------------------------------------------- +// Gradient functions +// --------------------------------------------------------------------------- +// 2D and 3D gradient functions — perform a dot product against a randomly +// chosen vector. The gradient vector is not normalized, but this only affects +// the overall "scale" of the result, so we account for it by multiplying in +// the corresponding "perlin" function. + +// 2D gradient: 8 possible directions (+-1,+-2) and (+-2,+-1) +fn mx_gradient_float_2d(hash: u32, x: f32, y: f32) -> f32 { + let h = hash & 7u; + let u = select(y, x, h < 4u); + let v = 2.0 * select(x, y, h < 4u); + return mx_negate_if(u, bool(h & 1u)) + mx_negate_if(v, bool(h & 2u)); +} + +// 3D gradient: vectors pointing to the edges of the cube +fn mx_gradient_float_3d(hash: u32, x: f32, y: f32, z: f32) -> f32 { + let h = hash & 15u; + let u = select(y, x, h < 8u); + let v = select(select(z, x, (h == 12u) || (h == 14u)), y, h < 4u); + return mx_negate_if(u, bool(h & 1u)) + mx_negate_if(v, bool(h & 2u)); +} + +// 2D vec3 gradient +fn mx_gradient_vec3_2d(hash: vec3, x: f32, y: f32) -> vec3f { + return vec3f( + mx_gradient_float_2d(hash.x, x, y), + mx_gradient_float_2d(hash.y, x, y), + mx_gradient_float_2d(hash.z, x, y) + ); +} + +// 3D vec3 gradient +fn mx_gradient_vec3_3d(hash: vec3, x: f32, y: f32, z: f32) -> vec3f { + return vec3f( + mx_gradient_float_3d(hash.x, x, y, z), + mx_gradient_float_3d(hash.y, x, y, z), + mx_gradient_float_3d(hash.z, x, y, z) + ); +} + +// Scaling factors to normalize the result of gradients above. +// Experimentally calculated: 2D = 0.6616, 3D = 0.9820. +fn mx_gradient_scale2d_f32(v: f32) -> f32 { return 0.6616 * v; } +fn mx_gradient_scale3d_f32(v: f32) -> f32 { return 0.9820 * v; } +fn mx_gradient_scale2d_vec3(v: vec3f) -> vec3f { return 0.6616 * v; } +fn mx_gradient_scale3d_vec3(v: vec3f) -> vec3f { return 0.9820 * v; } + +// --------------------------------------------------------------------------- +// Bob Jenkins hash (bjmix / bjfinal) +// --------------------------------------------------------------------------- + +// Bitwise circular rotation left by k bits (32-bit unsigned). +fn mx_rotl32(x: u32, k: i32) -> u32 { + return (x << u32(k)) | (x >> u32(32 - k)); +} + +fn mx_bjmix(a: ptr, b: ptr, c: ptr) { + *a -= *c; *a ^= mx_rotl32(*c, 4); *c += *b; + *b -= *a; *b ^= mx_rotl32(*a, 6); *a += *c; + *c -= *b; *c ^= mx_rotl32(*b, 8); *b += *a; + *a -= *c; *a ^= mx_rotl32(*c, 16); *c += *b; + *b -= *a; *b ^= mx_rotl32(*a, 19); *a += *c; + *c -= *b; *c ^= mx_rotl32(*b, 4); *b += *a; +} + +// Mix up and combine the bits of a, b, and c (returns a hash of those three +// original values without changing them). +fn mx_bjfinal(a: u32, b: u32, c: u32) -> u32 { + var a_ = a; var b_ = b; var c_ = c; + c_ ^= b_; c_ -= mx_rotl32(b_, 14); + a_ ^= c_; a_ -= mx_rotl32(c_, 11); + b_ ^= a_; b_ -= mx_rotl32(a_, 25); + c_ ^= b_; c_ -= mx_rotl32(b_, 16); + a_ ^= c_; a_ -= mx_rotl32(c_, 4); + b_ ^= a_; b_ -= mx_rotl32(a_, 14); + c_ ^= b_; c_ -= mx_rotl32(b_, 24); + return c_; +} + +// Convert a 32-bit integer into a floating point number in [0,1]. +fn mx_bits_to_01(bits: u32) -> f32 { + return f32(bits) / f32(0xffffffffu); +} + +// Quintic fade curve: 6t^5 - 15t^4 + 10t^3 +fn mx_fade(t: f32) -> f32 { + return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); +} + +// --------------------------------------------------------------------------- +// Hash functions +// --------------------------------------------------------------------------- +// WGSL has no function overloading, so we suffix by argument count: +// mx_hash_int_i1(x), mx_hash_int_i2(x,y), mx_hash_int_i3(x,y,z), etc. + +fn mx_hash_int_i1(x: i32) -> u32 { + let len = 1u; + let seed = 0xdeadbeefu + (len << 2u) + 13u; + return mx_bjfinal(seed + u32(x), seed, seed); +} + +fn mx_hash_int_i2(x: i32, y: i32) -> u32 { + let len = 2u; + var a = 0xdeadbeefu + (len << 2u) + 13u; + var b = a; var c = a; + a += u32(x); + b += u32(y); + return mx_bjfinal(a, b, c); +} + +fn mx_hash_int_i3(x: i32, y: i32, z: i32) -> u32 { + let len = 3u; + var a = 0xdeadbeefu + (len << 2u) + 13u; + var b = a; var c = a; + a += u32(x); + b += u32(y); + c += u32(z); + return mx_bjfinal(a, b, c); +} + +fn mx_hash_int_i4(x: i32, y: i32, z: i32, xx: i32) -> u32 { + let len = 4u; + var a = 0xdeadbeefu + (len << 2u) + 13u; + var b = a; var c = a; + a += u32(x); + b += u32(y); + c += u32(z); + mx_bjmix(&a, &b, &c); + a += u32(xx); + return mx_bjfinal(a, b, c); +} + +fn mx_hash_int_i5(x: i32, y: i32, z: i32, xx: i32, yy: i32) -> u32 { + let len = 5u; + var a = 0xdeadbeefu + (len << 2u) + 13u; + var b = a; var c = a; + a += u32(x); + b += u32(y); + c += u32(z); + mx_bjmix(&a, &b, &c); + a += u32(xx); + b += u32(yy); + return mx_bjfinal(a, b, c); +} + +// vec3 hash from 2 int args +fn mx_hash_vec3_i2(x: i32, y: i32) -> vec3 { + let h = mx_hash_int_i2(x, y); + return vec3( + (h ) & 0xFFu, + (h >> 8 ) & 0xFFu, + (h >> 16) & 0xFFu + ); +} + +// vec3 hash from 3 int args +fn mx_hash_vec3_i3(x: i32, y: i32, z: i32) -> vec3 { + let h = mx_hash_int_i3(x, y, z); + return vec3( + (h ) & 0xFFu, + (h >> 8 ) & 0xFFu, + (h >> 16) & 0xFFu + ); +} + +// --------------------------------------------------------------------------- +// Perlin noise +// --------------------------------------------------------------------------- + +// 2D Perlin noise (float output) +fn mx_perlin_noise_float_2d(p: vec2f) -> f32 { + var X: i32; var Y: i32; + let fx = mx_floorfrac(p.x, &X); + let fy = mx_floorfrac(p.y, &Y); + let u = mx_fade(fx); + let v = mx_fade(fy); + let result = mx_bilerp_f32( + mx_gradient_float_2d(mx_hash_int_i2(X, Y ), fx, fy ), + mx_gradient_float_2d(mx_hash_int_i2(X+1, Y ), fx - 1.0, fy ), + mx_gradient_float_2d(mx_hash_int_i2(X, Y+1), fx, fy - 1.0), + mx_gradient_float_2d(mx_hash_int_i2(X+1, Y+1), fx - 1.0, fy - 1.0), + u, v); + return mx_gradient_scale2d_f32(result); +} + +// 3D Perlin noise (float output) +fn mx_perlin_noise_float_3d(p: vec3f) -> f32 { + var X: i32; var Y: i32; var Z: i32; + let fx = mx_floorfrac(p.x, &X); + let fy = mx_floorfrac(p.y, &Y); + let fz = mx_floorfrac(p.z, &Z); + let u = mx_fade(fx); + let v = mx_fade(fy); + let w = mx_fade(fz); + let result = mx_trilerp_f32( + mx_gradient_float_3d(mx_hash_int_i3(X, Y, Z ), fx, fy, fz ), + mx_gradient_float_3d(mx_hash_int_i3(X+1, Y, Z ), fx - 1.0, fy, fz ), + mx_gradient_float_3d(mx_hash_int_i3(X, Y+1, Z ), fx, fy - 1.0, fz ), + mx_gradient_float_3d(mx_hash_int_i3(X+1, Y+1, Z ), fx - 1.0, fy - 1.0, fz ), + mx_gradient_float_3d(mx_hash_int_i3(X, Y, Z+1), fx, fy, fz - 1.0), + mx_gradient_float_3d(mx_hash_int_i3(X+1, Y, Z+1), fx - 1.0, fy, fz - 1.0), + mx_gradient_float_3d(mx_hash_int_i3(X, Y+1, Z+1), fx, fy - 1.0, fz - 1.0), + mx_gradient_float_3d(mx_hash_int_i3(X+1, Y+1, Z+1), fx - 1.0, fy - 1.0, fz - 1.0), + u, v, w); + return mx_gradient_scale3d_f32(result); +} + +// 2D Perlin noise (vec3 output) +fn mx_perlin_noise_vec3_2d(p: vec2f) -> vec3f { + var X: i32; var Y: i32; + let fx = mx_floorfrac(p.x, &X); + let fy = mx_floorfrac(p.y, &Y); + let u = mx_fade(fx); + let v = mx_fade(fy); + let result = mx_bilerp_vec3( + mx_gradient_vec3_2d(mx_hash_vec3_i2(X, Y ), fx, fy ), + mx_gradient_vec3_2d(mx_hash_vec3_i2(X+1, Y ), fx - 1.0, fy ), + mx_gradient_vec3_2d(mx_hash_vec3_i2(X, Y+1), fx, fy - 1.0), + mx_gradient_vec3_2d(mx_hash_vec3_i2(X+1, Y+1), fx - 1.0, fy - 1.0), + u, v); + return mx_gradient_scale2d_vec3(result); +} + +// 3D Perlin noise (vec3 output) +fn mx_perlin_noise_vec3_3d(p: vec3f) -> vec3f { + var X: i32; var Y: i32; var Z: i32; + let fx = mx_floorfrac(p.x, &X); + let fy = mx_floorfrac(p.y, &Y); + let fz = mx_floorfrac(p.z, &Z); + let u = mx_fade(fx); + let v = mx_fade(fy); + let w = mx_fade(fz); + let result = mx_trilerp_vec3( + mx_gradient_vec3_3d(mx_hash_vec3_i3(X, Y, Z ), fx, fy, fz ), + mx_gradient_vec3_3d(mx_hash_vec3_i3(X+1, Y, Z ), fx - 1.0, fy, fz ), + mx_gradient_vec3_3d(mx_hash_vec3_i3(X, Y+1, Z ), fx, fy - 1.0, fz ), + mx_gradient_vec3_3d(mx_hash_vec3_i3(X+1, Y+1, Z ), fx - 1.0, fy - 1.0, fz ), + mx_gradient_vec3_3d(mx_hash_vec3_i3(X, Y, Z+1), fx, fy, fz - 1.0), + mx_gradient_vec3_3d(mx_hash_vec3_i3(X+1, Y, Z+1), fx - 1.0, fy, fz - 1.0), + mx_gradient_vec3_3d(mx_hash_vec3_i3(X, Y+1, Z+1), fx, fy - 1.0, fz - 1.0), + mx_gradient_vec3_3d(mx_hash_vec3_i3(X+1, Y+1, Z+1), fx - 1.0, fy - 1.0, fz - 1.0), + u, v, w); + return mx_gradient_scale3d_vec3(result); +} + +// --------------------------------------------------------------------------- +// Cell noise +// --------------------------------------------------------------------------- + +// f32 input → f32 output +fn mx_cell_noise_float_f32(p: f32) -> f32 { + let ix = mx_floor(p); + return mx_bits_to_01(mx_hash_int_i1(ix)); +} + +// vec2 input → f32 output +fn mx_cell_noise_float_vec2(p: vec2f) -> f32 { + let ix = mx_floor(p.x); + let iy = mx_floor(p.y); + return mx_bits_to_01(mx_hash_int_i2(ix, iy)); +} + +// vec3 input → f32 output +fn mx_cell_noise_float_vec3(p: vec3f) -> f32 { + let ix = mx_floor(p.x); + let iy = mx_floor(p.y); + let iz = mx_floor(p.z); + return mx_bits_to_01(mx_hash_int_i3(ix, iy, iz)); +} + +// vec4 input → f32 output +fn mx_cell_noise_float_vec4(p: vec4f) -> f32 { + let ix = mx_floor(p.x); + let iy = mx_floor(p.y); + let iz = mx_floor(p.z); + let iw = mx_floor(p.w); + return mx_bits_to_01(mx_hash_int_i4(ix, iy, iz, iw)); +} + +// f32 input → vec3 output +fn mx_cell_noise_vec3_f32(p: f32) -> vec3f { + let ix = mx_floor(p); + return vec3f( + mx_bits_to_01(mx_hash_int_i2(ix, 0)), + mx_bits_to_01(mx_hash_int_i2(ix, 1)), + mx_bits_to_01(mx_hash_int_i2(ix, 2)) + ); +} + +// vec2 input → vec3 output +fn mx_cell_noise_vec3_vec2(p: vec2f) -> vec3f { + let ix = mx_floor(p.x); + let iy = mx_floor(p.y); + return vec3f( + mx_bits_to_01(mx_hash_int_i3(ix, iy, 0)), + mx_bits_to_01(mx_hash_int_i3(ix, iy, 1)), + mx_bits_to_01(mx_hash_int_i3(ix, iy, 2)) + ); +} + +// vec3 input → vec3 output +fn mx_cell_noise_vec3_vec3(p: vec3f) -> vec3f { + let ix = mx_floor(p.x); + let iy = mx_floor(p.y); + let iz = mx_floor(p.z); + return vec3f( + mx_bits_to_01(mx_hash_int_i4(ix, iy, iz, 0)), + mx_bits_to_01(mx_hash_int_i4(ix, iy, iz, 1)), + mx_bits_to_01(mx_hash_int_i4(ix, iy, iz, 2)) + ); +} + +// vec4 input → vec3 output +fn mx_cell_noise_vec3_vec4(p: vec4f) -> vec3f { + let ix = mx_floor(p.x); + let iy = mx_floor(p.y); + let iz = mx_floor(p.z); + let iw = mx_floor(p.w); + return vec3f( + mx_bits_to_01(mx_hash_int_i5(ix, iy, iz, iw, 0)), + mx_bits_to_01(mx_hash_int_i5(ix, iy, iz, iw, 1)), + mx_bits_to_01(mx_hash_int_i5(ix, iy, iz, iw, 2)) + ); +} + +// --------------------------------------------------------------------------- +// Fractal noise (fBm) +// --------------------------------------------------------------------------- + +fn mx_fractal2d_noise_float(p: vec2f, octaves: i32, lacunarity: f32, diminish: f32) -> f32 { + var result = 0.0; + var amplitude = 1.0; + var pp = p; + for (var i = 0; i < octaves; i++) { + result += amplitude * mx_perlin_noise_float_2d(pp); + amplitude *= diminish; + pp *= lacunarity; + } + return result; +} + +fn mx_fractal2d_noise_vec3(p: vec2f, octaves: i32, lacunarity: f32, diminish: f32) -> vec3f { + var result = vec3f(0.0); + var amplitude = 1.0; + var pp = p; + for (var i = 0; i < octaves; i++) { + result += amplitude * mx_perlin_noise_vec3_2d(pp); + amplitude *= diminish; + pp *= lacunarity; + } + return result; +} + +fn mx_fractal2d_noise_vec2(p: vec2f, octaves: i32, lacunarity: f32, diminish: f32) -> vec2f { + return vec2f( + mx_fractal2d_noise_float(p, octaves, lacunarity, diminish), + mx_fractal2d_noise_float(p + vec2f(19.0, 193.0), octaves, lacunarity, diminish) + ); +} + +fn mx_fractal2d_noise_vec4(p: vec2f, octaves: i32, lacunarity: f32, diminish: f32) -> vec4f { + let c = mx_fractal2d_noise_vec3(p, octaves, lacunarity, diminish); + let f = mx_fractal2d_noise_float(p + vec2f(19.0, 193.0), octaves, lacunarity, diminish); + return vec4f(c, f); +} + +fn mx_fractal3d_noise_float(p: vec3f, octaves: i32, lacunarity: f32, diminish: f32) -> f32 { + var result = 0.0; + var amplitude = 1.0; + var pp = p; + for (var i = 0; i < octaves; i++) { + result += amplitude * mx_perlin_noise_float_3d(pp); + amplitude *= diminish; + pp *= lacunarity; + } + return result; +} + +fn mx_fractal3d_noise_vec3(p: vec3f, octaves: i32, lacunarity: f32, diminish: f32) -> vec3f { + var result = vec3f(0.0); + var amplitude = 1.0; + var pp = p; + for (var i = 0; i < octaves; i++) { + result += amplitude * mx_perlin_noise_vec3_3d(pp); + amplitude *= diminish; + pp *= lacunarity; + } + return result; +} + +fn mx_fractal3d_noise_vec2(p: vec3f, octaves: i32, lacunarity: f32, diminish: f32) -> vec2f { + return vec2f( + mx_fractal3d_noise_float(p, octaves, lacunarity, diminish), + mx_fractal3d_noise_float(p + vec3f(19.0, 193.0, 17.0), octaves, lacunarity, diminish) + ); +} + +fn mx_fractal3d_noise_vec4(p: vec3f, octaves: i32, lacunarity: f32, diminish: f32) -> vec4f { + let c = mx_fractal3d_noise_vec3(p, octaves, lacunarity, diminish); + let f = mx_fractal3d_noise_float(p + vec3f(19.0, 193.0, 17.0), octaves, lacunarity, diminish); + return vec4f(c, f); +} + +// --------------------------------------------------------------------------- +// Worley noise +// --------------------------------------------------------------------------- + +// 2D cell position for Worley noise +fn mx_worley_cell_position_2d(x: i32, y: i32, xoff: i32, yoff: i32, jitter: f32) -> vec2f { + let tmp = mx_cell_noise_vec3_vec2(vec2f(f32(x + xoff), f32(y + yoff))); + var off = vec2f(tmp.x, tmp.y); + off -= 0.5; + off *= jitter; + off += 0.5; + return vec2f(f32(x), f32(y)) + off; +} + +// 3D cell position for Worley noise +fn mx_worley_cell_position_3d(x: i32, y: i32, z: i32, xoff: i32, yoff: i32, zoff: i32, jitter: f32) -> vec3f { + var off = mx_cell_noise_vec3_vec3(vec3f(f32(x + xoff), f32(y + yoff), f32(z + zoff))); + off -= 0.5; + off *= jitter; + off += 0.5; + return vec3f(f32(x), f32(y), f32(z)) + off; +} + +// 2D Worley distance +fn mx_worley_distance_2d(p: vec2f, x: i32, y: i32, xoff: i32, yoff: i32, jitter: f32, metric: i32) -> f32 { + let cellpos = mx_worley_cell_position_2d(x, y, xoff, yoff, jitter); + let diff = cellpos - p; + if (metric == 2) { + return abs(diff.x) + abs(diff.y); // Manhattan + } + if (metric == 3) { + return max(abs(diff.x), abs(diff.y)); // Chebyshev + } + return dot(diff, diff); // Euclidean^2 +} + +// 3D Worley distance +fn mx_worley_distance_3d(p: vec3f, x: i32, y: i32, z: i32, xoff: i32, yoff: i32, zoff: i32, jitter: f32, metric: i32) -> f32 { + let cellpos = mx_worley_cell_position_3d(x, y, z, xoff, yoff, zoff, jitter); + let diff = cellpos - p; + if (metric == 2) { + return abs(diff.x) + abs(diff.y) + abs(diff.z); // Manhattan + } + if (metric == 3) { + return max(max(abs(diff.x), abs(diff.y)), abs(diff.z)); // Chebyshev + } + return dot(diff, diff); // Euclidean^2 +} + +// 2D Worley noise (float output) +fn mx_worley_noise_float_2d(p: vec2f, jitter: f32, style: i32, metric: i32) -> f32 { + var X: i32; var Y: i32; + let localpos = vec2f(mx_floorfrac(p.x, &X), mx_floorfrac(p.y, &Y)); + var sqdist = 1e6; + var minpos = vec2f(0.0, 0.0); + for (var x = -1; x <= 1; x++) { + for (var y = -1; y <= 1; y++) { + let dist = mx_worley_distance_2d(localpos, x, y, X, Y, jitter, metric); + let cellpos = mx_worley_cell_position_2d(x, y, X, Y, jitter) - localpos; + if (dist < sqdist) { + sqdist = dist; + minpos = cellpos; + } + } + } + if (style == 1) { + return mx_cell_noise_float_vec2(minpos + p); + } else { + if (metric == 0) { sqdist = sqrt(sqdist); } + return sqdist; + } +} + +// 2D Worley noise (vec2 output) +fn mx_worley_noise_vec2_2d(p: vec2f, jitter: f32, style: i32, metric: i32) -> vec2f { + var X: i32; var Y: i32; + let localpos = vec2f(mx_floorfrac(p.x, &X), mx_floorfrac(p.y, &Y)); + var sqdist = vec2f(1e6, 1e6); + var minpos = vec2f(0.0, 0.0); + for (var x = -1; x <= 1; x++) { + for (var y = -1; y <= 1; y++) { + let dist = mx_worley_distance_2d(localpos, x, y, X, Y, jitter, metric); + let cellpos = mx_worley_cell_position_2d(x, y, X, Y, jitter) - localpos; + if (dist < sqdist.x) { + sqdist.y = sqdist.x; + sqdist.x = dist; + minpos = cellpos; + } else if (dist < sqdist.y) { + sqdist.y = dist; + } + } + } + if (style == 1) { + let tmp = mx_cell_noise_vec3_vec2(minpos + p); + return vec2f(tmp.x, tmp.y); + } else { + if (metric == 0) { sqdist = sqrt(sqdist); } + return sqdist; + } +} + +// 2D Worley noise (vec3 output) +fn mx_worley_noise_vec3_2d(p: vec2f, jitter: f32, style: i32, metric: i32) -> vec3f { + var X: i32; var Y: i32; + let localpos = vec2f(mx_floorfrac(p.x, &X), mx_floorfrac(p.y, &Y)); + var sqdist = vec3f(1e6, 1e6, 1e6); + var minpos = vec2f(0.0, 0.0); + for (var x = -1; x <= 1; x++) { + for (var y = -1; y <= 1; y++) { + let dist = mx_worley_distance_2d(localpos, x, y, X, Y, jitter, metric); + let cellpos = mx_worley_cell_position_2d(x, y, X, Y, jitter) - localpos; + if (dist < sqdist.x) { + sqdist.z = sqdist.y; + sqdist.y = sqdist.x; + sqdist.x = dist; + minpos = cellpos; + } else if (dist < sqdist.y) { + sqdist.z = sqdist.y; + sqdist.y = dist; + } else if (dist < sqdist.z) { + sqdist.z = dist; + } + } + } + if (style == 1) { + return mx_cell_noise_vec3_vec2(minpos + p); + } else { + if (metric == 0) { sqdist = sqrt(sqdist); } + return sqdist; + } +} + +// 3D Worley noise (float output) +fn mx_worley_noise_float_3d(p: vec3f, jitter: f32, style: i32, metric: i32) -> f32 { + var X: i32; var Y: i32; var Z: i32; + let localpos = vec3f(mx_floorfrac(p.x, &X), mx_floorfrac(p.y, &Y), mx_floorfrac(p.z, &Z)); + var sqdist = 1e6; + var minpos = vec3f(0.0, 0.0, 0.0); + for (var x = -1; x <= 1; x++) { + for (var y = -1; y <= 1; y++) { + for (var z = -1; z <= 1; z++) { + let dist = mx_worley_distance_3d(localpos, x, y, z, X, Y, Z, jitter, metric); + let cellpos = mx_worley_cell_position_3d(x, y, z, X, Y, Z, jitter) - localpos; + if (dist < sqdist) { + sqdist = dist; + minpos = cellpos; + } + } + } + } + if (style == 1) { + return mx_cell_noise_float_vec3(minpos + p); + } else { + if (metric == 0) { sqdist = sqrt(sqdist); } + return sqdist; + } +} + +// 3D Worley noise (vec2 output) +fn mx_worley_noise_vec2_3d(p: vec3f, jitter: f32, style: i32, metric: i32) -> vec2f { + var X: i32; var Y: i32; var Z: i32; + let localpos = vec3f(mx_floorfrac(p.x, &X), mx_floorfrac(p.y, &Y), mx_floorfrac(p.z, &Z)); + var sqdist = vec2f(1e6, 1e6); + var minpos = vec3f(0.0, 0.0, 0.0); + for (var x = -1; x <= 1; x++) { + for (var y = -1; y <= 1; y++) { + for (var z = -1; z <= 1; z++) { + let dist = mx_worley_distance_3d(localpos, x, y, z, X, Y, Z, jitter, metric); + let cellpos = mx_worley_cell_position_3d(x, y, z, X, Y, Z, jitter) - localpos; + if (dist < sqdist.x) { + sqdist.y = sqdist.x; + sqdist.x = dist; + minpos = cellpos; + } else if (dist < sqdist.y) { + sqdist.y = dist; + } + } + } + } + if (style == 1) { + let tmp = mx_cell_noise_vec3_vec3(minpos + p); + return vec2f(tmp.x, tmp.y); + } else { + if (metric == 0) { sqdist = sqrt(sqdist); } + return sqdist; + } +} + +// 3D Worley noise (vec3 output) +fn mx_worley_noise_vec3_3d(p: vec3f, jitter: f32, style: i32, metric: i32) -> vec3f { + var X: i32; var Y: i32; var Z: i32; + let localpos = vec3f(mx_floorfrac(p.x, &X), mx_floorfrac(p.y, &Y), mx_floorfrac(p.z, &Z)); + var sqdist = vec3f(1e6, 1e6, 1e6); + var minpos = vec3f(0.0, 0.0, 0.0); + for (var x = -1; x <= 1; x++) { + for (var y = -1; y <= 1; y++) { + for (var z = -1; z <= 1; z++) { + let dist = mx_worley_distance_3d(localpos, x, y, z, X, Y, Z, jitter, metric); + let cellpos = mx_worley_cell_position_3d(x, y, z, X, Y, Z, jitter) - localpos; + if (dist < sqdist.x) { + sqdist.z = sqdist.y; + sqdist.y = sqdist.x; + sqdist.x = dist; + minpos = cellpos; + } else if (dist < sqdist.y) { + sqdist.z = sqdist.y; + sqdist.y = dist; + } else if (dist < sqdist.z) { + sqdist.z = dist; + } + } + } + } + if (style == 1) { + return mx_cell_noise_vec3_vec3(minpos + p); + } else { + if (metric == 0) { sqdist = sqrt(sqdist); } + return sqdist; + } +} diff --git a/libraries/stdlib/genwgsl/lib/mx_transform_uv.wgsl b/libraries/stdlib/genwgsl/lib/mx_transform_uv.wgsl new file mode 100644 index 0000000000..5d6d0725bb --- /dev/null +++ b/libraries/stdlib/genwgsl/lib/mx_transform_uv.wgsl @@ -0,0 +1,4 @@ +// UV transform: scale and offset. +fn mx_transform_uv(uv: vec2f, uv_scale: vec2f, uv_offset: vec2f) -> vec2f { + return uv * uv_scale + uv_offset; +} diff --git a/libraries/stdlib/genwgsl/lib/mx_transform_uv_vflip.wgsl b/libraries/stdlib/genwgsl/lib/mx_transform_uv_vflip.wgsl new file mode 100644 index 0000000000..76459ca49e --- /dev/null +++ b/libraries/stdlib/genwgsl/lib/mx_transform_uv_vflip.wgsl @@ -0,0 +1,5 @@ +// UV transform with vertical flip: scale, offset, then flip V. +fn mx_transform_uv(uv: vec2f, uv_scale: vec2f, uv_offset: vec2f) -> vec2f { + let transformed = uv * uv_scale + uv_offset; + return vec2f(transformed.x, 1.0 - transformed.y); +} diff --git a/libraries/stdlib/genwgsl/mx_hextiledimage.wgsl b/libraries/stdlib/genwgsl/mx_hextiledimage.wgsl new file mode 100644 index 0000000000..785511a7d6 --- /dev/null +++ b/libraries/stdlib/genwgsl/mx_hextiledimage.wgsl @@ -0,0 +1,80 @@ +#include "lib/$fileTransformUv" +#include "lib/mx_hextile.wgsl" + +// +// Morten S. Mikkelsen, Practical Real-Time Hex-Tiling, JCGT vol. 11, no. 2, 2022 +// http://jcgt.org/published/0011/03/05/ + +fn mx_hextiledimage_color3( + tex: texture_2d, + tex_sampler: sampler, + default_value: vec3f, + tex_coord: vec2f, + tiling: vec2f, + rotation: f32, + rotation_range: vec2f, + scale: f32, + scale_range: vec2f, + offset_val: f32, + offset_range: vec2f, + falloff: f32, + falloff_contrast: f32, + lumacoeffs: vec3f, + result: ptr +) { + let coord = mx_transform_uv(tex_coord, tiling, vec2f(0.0)); + + let tile_data = mx_hextile_coord(coord, rotation, rotation_range, scale, scale_range, offset_val, offset_range); + + let c1 = textureSampleGrad(tex, tex_sampler, tile_data.coords0, tile_data.ddx0, tile_data.ddy0).rgb; + let c2 = textureSampleGrad(tex, tex_sampler, tile_data.coords1, tile_data.ddx1, tile_data.ddy1).rgb; + let c3 = textureSampleGrad(tex, tex_sampler, tile_data.coords2, tile_data.ddx2, tile_data.ddy2).rgb; + + // Luminance as weights. + var cw = vec3f(dot(c1, lumacoeffs), dot(c2, lumacoeffs), dot(c3, lumacoeffs)); + cw = mix(vec3f(1.0), cw, vec3f(falloff_contrast)); + + let w = mx_hextile_compute_blend_weights(cw, tile_data.weights, falloff); + + *result = w.x * c1 + w.y * c2 + w.z * c3; +} + +fn mx_hextiledimage_color4( + tex: texture_2d, + tex_sampler: sampler, + default_value: vec4f, + tex_coord: vec2f, + tiling: vec2f, + rotation: f32, + rotation_range: vec2f, + scale: f32, + scale_range: vec2f, + offset_val: f32, + offset_range: vec2f, + falloff: f32, + falloff_contrast: f32, + lumacoeffs: vec3f, + result: ptr +) { + let coord = mx_transform_uv(tex_coord, tiling, vec2f(0.0)); + + let tile_data = mx_hextile_coord(coord, rotation, rotation_range, scale, scale_range, offset_val, offset_range); + + let c1 = textureSampleGrad(tex, tex_sampler, tile_data.coords0, tile_data.ddx0, tile_data.ddy0); + let c2 = textureSampleGrad(tex, tex_sampler, tile_data.coords1, tile_data.ddx1, tile_data.ddy1); + let c3 = textureSampleGrad(tex, tex_sampler, tile_data.coords2, tile_data.ddx2, tile_data.ddy2); + + // Luminance as weights. + var cw = vec3f(dot(c1.rgb, lumacoeffs), dot(c2.rgb, lumacoeffs), dot(c3.rgb, lumacoeffs)); + cw = mix(vec3f(1.0), cw, vec3f(falloff_contrast)); + + let w = mx_hextile_compute_blend_weights(cw, tile_data.weights, falloff); + + // Alpha is averaged, then optionally gain-adjusted. + var a = (c1.a + c2.a + c3.a) / 3.0; + if (falloff != 0.5) { + a = mx_schlick_gain(a, falloff); + } + + (*result) = vec4f(w.x * c1.rgb + w.y * c2.rgb + w.z * c3.rgb, a); +} diff --git a/libraries/stdlib/genwgsl/mx_hextilednormalmap.wgsl b/libraries/stdlib/genwgsl/mx_hextilednormalmap.wgsl new file mode 100644 index 0000000000..c580cab41f --- /dev/null +++ b/libraries/stdlib/genwgsl/mx_hextilednormalmap.wgsl @@ -0,0 +1,64 @@ +#include "lib/$fileTransformUv" +#include "lib/mx_hextile.wgsl" +#include "lib/mx_geometry.wgsl" + +// +// Morten S. Mikkelsen, Practical Real-Time Hex-Tiling, JCGT vol. 11, no. 2, 2022 +// http://jcgt.org/published/0011/03/05/ + +fn mx_hextilednormalmap_vector3( + tex: texture_2d, + tex_sampler: sampler, + default_value: vec3f, + tex_coord: vec2f, + tiling: vec2f, + rotation: f32, + rotation_range: vec2f, + scale: f32, + scale_range: vec2f, + offset_val: f32, + offset_range: vec2f, + falloff: f32, + strength: f32, + flip_g: bool, + N: vec3f, + T: vec3f, + B: vec3f, + result: ptr +) { + let coord = mx_transform_uv(tex_coord, tiling, vec2f(0.0)); + + let tile_data = mx_hextile_coord(coord, rotation, rotation_range, scale, scale_range, offset_val, offset_range); + + var nm1 = textureSampleGrad(tex, tex_sampler, tile_data.coords0, tile_data.ddx0, tile_data.ddy0).xyz; + var nm2 = textureSampleGrad(tex, tex_sampler, tile_data.coords1, tile_data.ddx1, tile_data.ddy1).xyz; + var nm3 = textureSampleGrad(tex, tex_sampler, tile_data.coords2, tile_data.ddx2, tile_data.ddy2).xyz; + + if (flip_g) { + nm1.y = 1.0 - nm1.y; + nm2.y = 1.0 - nm2.y; + nm3.y = 1.0 - nm3.y; + } + + // Normal map to shading normal. + nm1 = 2.0 * nm1 - 1.0; + nm2 = 2.0 * nm2 - 1.0; + nm3 = 2.0 * nm3 - 1.0; + let tangent_rot_mat1 = mx_axis_rotation_matrix(N, -tile_data.rotations.x); + let tangent_rot_mat2 = mx_axis_rotation_matrix(N, -tile_data.rotations.y); + let tangent_rot_mat3 = mx_axis_rotation_matrix(N, -tile_data.rotations.z); + let T1 = (tangent_rot_mat1 * T) * strength; + let T2 = (tangent_rot_mat2 * T) * strength; + let T3 = (tangent_rot_mat3 * T) * strength; + let B1 = (tangent_rot_mat1 * B) * strength; + let B2 = (tangent_rot_mat2 * B) * strength; + let B3 = (tangent_rot_mat3 * B) * strength; + let N1 = normalize(T1 * nm1.x + B1 * nm1.y + N * nm1.z); + let N2 = normalize(T2 * nm2.x + B2 * nm2.y + N * nm2.z); + let N3 = normalize(T3 * nm3.x + B3 * nm3.y + N * nm3.z); + + // Blend weights. + let w = mx_hextile_compute_blend_weights(vec3f(1.0), tile_data.weights, falloff); + + *result = mx_gradient_blend_3_normals(N, N1, w.x, N2, w.y, N3, w.z); +} diff --git a/libraries/stdlib/genwgsl/mx_image_color3.wgsl b/libraries/stdlib/genwgsl/mx_image_color3.wgsl new file mode 100644 index 0000000000..2c1ce40e71 --- /dev/null +++ b/libraries/stdlib/genwgsl/mx_image_color3.wgsl @@ -0,0 +1,6 @@ +#include "lib/$fileTransformUv" + +fn mx_image_color3(tex: texture_2d, samp: sampler, layer: i32, defaultval: vec3f, texcoord: vec2f, uaddressmode: i32, vaddressmode: i32, filtertype: i32, framerange: i32, frameoffset: i32, frameendaction: i32, uv_scale: vec2f, uv_offset: vec2f, result: ptr) { + let uv = mx_transform_uv(texcoord, uv_scale, uv_offset); + *result = textureSample(tex, samp, uv).rgb; +} diff --git a/libraries/stdlib/genwgsl/mx_image_color4.wgsl b/libraries/stdlib/genwgsl/mx_image_color4.wgsl new file mode 100644 index 0000000000..fe96f67a96 --- /dev/null +++ b/libraries/stdlib/genwgsl/mx_image_color4.wgsl @@ -0,0 +1,6 @@ +#include "lib/$fileTransformUv" + +fn mx_image_color4(tex: texture_2d, samp: sampler, layer: i32, defaultval: vec4f, texcoord: vec2f, uaddressmode: i32, vaddressmode: i32, filtertype: i32, framerange: i32, frameoffset: i32, frameendaction: i32, uv_scale: vec2f, uv_offset: vec2f, result: ptr) { + let uv = mx_transform_uv(texcoord, uv_scale, uv_offset); + *result = textureSample(tex, samp, uv); +} diff --git a/libraries/stdlib/genwgsl/mx_image_float.wgsl b/libraries/stdlib/genwgsl/mx_image_float.wgsl new file mode 100644 index 0000000000..99d184dbe6 --- /dev/null +++ b/libraries/stdlib/genwgsl/mx_image_float.wgsl @@ -0,0 +1,6 @@ +#include "lib/$fileTransformUv" + +fn mx_image_float(tex: texture_2d, samp: sampler, layer: i32, defaultval: f32, texcoord: vec2f, uaddressmode: i32, vaddressmode: i32, filtertype: i32, framerange: i32, frameoffset: i32, frameendaction: i32, uv_scale: vec2f, uv_offset: vec2f, result: ptr) { + let uv = mx_transform_uv(texcoord, uv_scale, uv_offset); + *result = textureSample(tex, samp, uv).r; +} diff --git a/libraries/stdlib/genwgsl/mx_image_vector2.wgsl b/libraries/stdlib/genwgsl/mx_image_vector2.wgsl new file mode 100644 index 0000000000..77da96a3e7 --- /dev/null +++ b/libraries/stdlib/genwgsl/mx_image_vector2.wgsl @@ -0,0 +1,6 @@ +#include "lib/$fileTransformUv" + +fn mx_image_vector2(tex: texture_2d, samp: sampler, layer: i32, defaultval: vec2f, texcoord: vec2f, uaddressmode: i32, vaddressmode: i32, filtertype: i32, framerange: i32, frameoffset: i32, frameendaction: i32, uv_scale: vec2f, uv_offset: vec2f, result: ptr) { + let uv = mx_transform_uv(texcoord, uv_scale, uv_offset); + *result = textureSample(tex, samp, uv).rg; +} diff --git a/libraries/stdlib/genwgsl/mx_image_vector3.wgsl b/libraries/stdlib/genwgsl/mx_image_vector3.wgsl new file mode 100644 index 0000000000..08b67446ea --- /dev/null +++ b/libraries/stdlib/genwgsl/mx_image_vector3.wgsl @@ -0,0 +1,6 @@ +#include "lib/$fileTransformUv" + +fn mx_image_vector3(tex: texture_2d, samp: sampler, layer: i32, defaultval: vec3f, texcoord: vec2f, uaddressmode: i32, vaddressmode: i32, filtertype: i32, framerange: i32, frameoffset: i32, frameendaction: i32, uv_scale: vec2f, uv_offset: vec2f, result: ptr) { + let uv = mx_transform_uv(texcoord, uv_scale, uv_offset); + *result = textureSample(tex, samp, uv).rgb; +} diff --git a/libraries/stdlib/genwgsl/mx_image_vector4.wgsl b/libraries/stdlib/genwgsl/mx_image_vector4.wgsl new file mode 100644 index 0000000000..19b0f9c456 --- /dev/null +++ b/libraries/stdlib/genwgsl/mx_image_vector4.wgsl @@ -0,0 +1,6 @@ +#include "lib/$fileTransformUv" + +fn mx_image_vector4(tex: texture_2d, samp: sampler, layer: i32, defaultval: vec4f, texcoord: vec2f, uaddressmode: i32, vaddressmode: i32, filtertype: i32, framerange: i32, frameoffset: i32, frameendaction: i32, uv_scale: vec2f, uv_offset: vec2f, result: ptr) { + let uv = mx_transform_uv(texcoord, uv_scale, uv_offset); + *result = textureSample(tex, samp, uv); +} diff --git a/libraries/stdlib/genwgsl/stdlib_genwgsl_impl.mtlx b/libraries/stdlib/genwgsl/stdlib_genwgsl_impl.mtlx new file mode 100644 index 0000000000..815e34d8cf --- /dev/null +++ b/libraries/stdlib/genwgsl/stdlib_genwgsl_impl.mtlx @@ -0,0 +1,768 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libraries/targets/genwgsl.mtlx b/libraries/targets/genwgsl.mtlx new file mode 100644 index 0000000000..438095a5f4 --- /dev/null +++ b/libraries/targets/genwgsl.mtlx @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index 12aba95f51..17b20aad56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,7 @@ result = "{major}.{minor}.{build}" [tool.scikit-build.cmake.define] MATERIALX_BUILD_SHARED_LIBS = 'OFF' # Be explicit MATERIALX_BUILD_PYTHON = 'ON' +MATERIALX_BUILD_GEN_WGSL = 'ON' MATERIALX_BUILD_DOCS = 'ON' MATERIALX_PYTHON_FORCE_REPLACE_DOCS = 'ON' MATERIALX_TEST_RENDER = 'OFF' diff --git a/python/Scripts/generateshader.py b/python/Scripts/generateshader.py index 46ff359043..72f97344c7 100644 --- a/python/Scripts/generateshader.py +++ b/python/Scripts/generateshader.py @@ -1,7 +1,7 @@ #!/usr/bin/env python ''' Generate shader code for each renderable element in a MaterialX document or folder. -The currently supported target languages are GLSL, ESSL, MSL, OSL, and MDL. +The currently supported target languages are GLSL, ESSL, MSL, OSL, MDL, Slang, and WGSL. ''' import sys, os, argparse, subprocess @@ -14,6 +14,12 @@ import MaterialX.PyMaterialXGenSlang as mx_gen_slang import MaterialX.PyMaterialXGenShader as mx_gen_shader +# The WGSL generator is an optional +try: + import MaterialX.PyMaterialXGenWgsl as mx_gen_wgsl +except ImportError: + mx_gen_wgsl = None + def validateCode(sourceCodeFile, codevalidator, codevalidatorArgs): if codevalidator: cmd = codevalidator.split() @@ -100,7 +106,11 @@ def main(): elif gentarget == 'vulkan': shadergen = mx_gen_glsl.VkShaderGenerator.create() elif gentarget == 'wgsl': - shadergen = mx_gen_glsl.WgslShaderGenerator.create() + if mx_gen_wgsl is None: + print('WGSL shader generation is not available in this build ' + '(MATERIALX_BUILD_GEN_WGSL was disabled).') + sys.exit(1) + shadergen = mx_gen_wgsl.WgslShaderGenerator.create() elif gentarget == 'msl': shadergen = mx_gen_msl.MslShaderGenerator.create() elif gentarget == 'slang': diff --git a/source/JsMaterialX/CMakeLists.txt b/source/JsMaterialX/CMakeLists.txt index 60ecd9048a..091db4b311 100644 --- a/source/JsMaterialX/CMakeLists.txt +++ b/source/JsMaterialX/CMakeLists.txt @@ -102,8 +102,12 @@ add_executable(JsMaterialXGenShader MaterialXLib.cpp ${GENESSL_DEPS}) if (MATERIALX_BUILD_GEN_GLSL) - message("JS: Building JsMaterialXGenShader with GLSL, VK, WGSL support") - target_sources(JsMaterialXGenShader PRIVATE ${GENGLSL_DEPS} ${GENVK_DEPS} ${GENWGSL_DEPS}) + message("JS: Building JsMaterialXGenShader with GLSL, VK support") + target_sources(JsMaterialXGenShader PRIVATE ${GENGLSL_DEPS} ${GENVK_DEPS}) +endif() +if (MATERIALX_BUILD_GEN_WGSL) + message("JS: Building JsMaterialXGenShader with WGSL support") + target_sources(JsMaterialXGenShader PRIVATE ${GENWGSL_DEPS}) endif() if (MATERIALX_BUILD_GEN_OSL) message("JS: Building JsMaterialXGenShader with OSL support") @@ -163,6 +167,9 @@ endif() if (MATERIALX_BUILD_GEN_SLANG) target_link_libraries(JsMaterialXGenShader PUBLIC MaterialXGenSlang) endif() +if (MATERIALX_BUILD_GEN_WGSL) + target_link_libraries(JsMaterialXGenShader PUBLIC MaterialXGenWgsl) +endif() # Install the JavaScript output install(TARGETS JsMaterialXCore DESTINATION "JavaScript/MaterialX") diff --git a/source/JsMaterialX/JsMaterialXGenWgsl/JsWgslShaderGenerator.cpp b/source/JsMaterialX/JsMaterialXGenWgsl/JsWgslShaderGenerator.cpp index b0c05d3228..34cff9b5be 100644 --- a/source/JsMaterialX/JsMaterialXGenWgsl/JsWgslShaderGenerator.cpp +++ b/source/JsMaterialX/JsMaterialXGenWgsl/JsWgslShaderGenerator.cpp @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 // -#include +#include #include #include @@ -22,6 +22,6 @@ namespace EMSCRIPTEN_BINDINGS(WgslShaderGenerator) { - ems::class_>("WgslShaderGenerator") + ems::class_>("WgslShaderGenerator") .class_function("create", &WgslShaderGenerator_create); } diff --git a/source/MaterialXGenGlsl/WgslResourceBindingContext.cpp b/source/MaterialXGenGlsl/WgslResourceBindingContext.cpp deleted file mode 100644 index b9ceb270a4..0000000000 --- a/source/MaterialXGenGlsl/WgslResourceBindingContext.cpp +++ /dev/null @@ -1,105 +0,0 @@ -// -// Copyright Contributors to the MaterialX Project -// SPDX-License-Identifier: Apache-2.0 -// - -#include - -#include -#include - -MATERIALX_NAMESPACE_BEGIN - -// -// WgslResourceBindingContext methods -// - -WgslResourceBindingContext::WgslResourceBindingContext(size_t uniformBindingLocation) : - VkResourceBindingContext(uniformBindingLocation) -{ -} - -// Copied from VkResourceBindingContext::emitResourceBindings(). -// Modified the Type::FILENAME uniform codegen. -void WgslResourceBindingContext::emitResourceBindings(GenContext& context, const VariableBlock& uniforms, ShaderStage& stage) -{ - const ShaderGenerator& generator = context.getShaderGenerator(); - const Syntax& syntax = generator.getSyntax(); - - // First, emit all value uniforms in a block with single layout binding - bool hasValueUniforms = false; - for (auto uniform : uniforms.getVariableOrder()) - { - if (uniform->getType() != Type::FILENAME) - { - hasValueUniforms = true; - break; - } - } - if (hasValueUniforms) - { - generator.emitLine("layout (std140, binding=" + std::to_string(_hwUniformBindLocation++) + ") " + - syntax.getUniformQualifier() + " " + uniforms.getName() + "_" + stage.getName(), - stage, false); - generator.emitScopeBegin(stage); - for (auto uniform : uniforms.getVariableOrder()) - { - if (uniform->getType() != Type::FILENAME) - { - if ( uniform->getType() == Type::BOOLEAN ) - { - // Cannot have boolean uniforms in WGSL - std::cerr << "Warning: WGSL does not allow boolean types to be stored in uniform or storage address spaces." << std::endl; - - // Set uniform type to integer - uniform->setType( Type::INTEGER ); - - // Write declaration as normal - generator.emitLineBegin(stage); - generator.emitVariableDeclaration(uniform, EMPTY_STRING, context, stage, false); - generator.emitString(Syntax::SEMICOLON, stage); - generator.emitLineEnd(stage, false); - - // Add macro to treat any follow usages of this variable as a boolean - // eg. u_myUniformBool -> bool(u_myUniformBool) - generator.emitString("#define " + uniform->getVariable() + " bool(" + uniform->getVariable() + ")", stage); - generator.emitLineBreak(stage); - } - else - { - generator.emitLineBegin(stage); - generator.emitVariableDeclaration(uniform, EMPTY_STRING, context, stage, false); - generator.emitString(Syntax::SEMICOLON, stage); - generator.emitLineEnd(stage, false); - } - - } - } - generator.emitScopeEnd(stage, true); - } - - // Second, emit all sampler uniforms as separate uniforms with separate layout bindings - for (auto uniform : uniforms.getVariableOrder()) - { - if (uniform->getType() == Type::FILENAME) - { - // Bind separately as texture2D + sampler - // - // NOTE: the *_texture and *_sampler binding names method below expect that - // variables from HwShaderGenerator.cpp (HW::ENV_RADIANCE_SPLIT and HW::ENV_IRRADIANCE_SPLIT) - // use the same naming convention as here. - // - generator.emitString("layout (binding=" + std::to_string(_hwUniformBindLocation++) + ") " + syntax.getUniformQualifier() + " ", stage); - generator.emitString(string("texture2D ")+uniform->getVariable()+"_texture", stage); - generator.emitLineEnd(stage, true); - - generator.emitString("layout (binding=" + std::to_string(_hwUniformBindLocation++) + ") " + syntax.getUniformQualifier() + " ", stage); - generator.emitString(string("sampler ")+uniform->getVariable()+"_sampler", stage); - generator.emitLineEnd(stage, true); - } - } - - generator.emitLineBreak(stage); -} - -MATERIALX_NAMESPACE_END diff --git a/source/MaterialXGenGlsl/WgslResourceBindingContext.h b/source/MaterialXGenGlsl/WgslResourceBindingContext.h deleted file mode 100644 index 7b39b65661..0000000000 --- a/source/MaterialXGenGlsl/WgslResourceBindingContext.h +++ /dev/null @@ -1,39 +0,0 @@ -// -// Copyright Contributors to the MaterialX Project -// SPDX-License-Identifier: Apache-2.0 -// - -#ifndef MATERIALX_WGSLRESOURCEBINDING_H -#define MATERIALX_WGSLRESOURCEBINDING_H - -/// @file -/// Vulkan GLSL resource binding context for WGSL - -#include - -#include - -MATERIALX_NAMESPACE_BEGIN - -/// Shared pointer to a WgslResourceBindingContext -using WgslResourceBindingContextPtr = shared_ptr; - -/// @class WgslResourceBindingContext -/// Class representing a resource binding for Vulkan Glsl shader resources. -class MX_GENGLSL_API WgslResourceBindingContext : public VkResourceBindingContext -{ - public: - WgslResourceBindingContext(size_t uniformBindingLocation); - - static WgslResourceBindingContextPtr create(size_t uniformBindingLocation = 0) - { - return std::make_shared(uniformBindingLocation); - } - - // Emit uniforms with binding information - void emitResourceBindings(GenContext& context, const VariableBlock& uniforms, ShaderStage& stage) override; -}; - -MATERIALX_NAMESPACE_END - -#endif diff --git a/source/MaterialXGenGlsl/WgslShaderGenerator.cpp b/source/MaterialXGenGlsl/WgslShaderGenerator.cpp deleted file mode 100644 index d39a2498f4..0000000000 --- a/source/MaterialXGenGlsl/WgslShaderGenerator.cpp +++ /dev/null @@ -1,67 +0,0 @@ -// -// Copyright Contributors to the MaterialX Project -// SPDX-License-Identifier: Apache-2.0 -// - -#include -#include - -#include - -MATERIALX_NAMESPACE_BEGIN - -const string WgslShaderGenerator::LIGHTDATA_TYPEVAR_STRING = "light_type"; - -WgslShaderGenerator::WgslShaderGenerator(TypeSystemPtr typeSystem) : - VkShaderGenerator(typeSystem) -{ - _syntax = WgslSyntax::create(typeSystem); - - // Set binding context to handle resource binding layouts - _resourceBindingCtx = std::make_shared(0); - - // For functions described in ::emitSpecularEnvironment() - // override map value from HwShaderGenerator - _tokenSubstitutions[HW::T_ENV_RADIANCE] = HW::ENV_RADIANCE_SPLIT; - _tokenSubstitutions[HW::T_ENV_RADIANCE_SAMPLER2D] = HW::ENV_RADIANCE_SAMPLER2D_SPLIT; - _tokenSubstitutions[HW::T_ENV_IRRADIANCE] = HW::ENV_IRRADIANCE_SPLIT; - _tokenSubstitutions[HW::T_ENV_IRRADIANCE_SAMPLER2D] = HW::ENV_IRRADIANCE_SAMPLER2D_SPLIT; - _tokenSubstitutions[HW::T_TEX_SAMPLER_SAMPLER2D] = HW::TEX_SAMPLER_SAMPLER2D_SPLIT; - _tokenSubstitutions[HW::T_TEX_SAMPLER_SIGNATURE] = HW::TEX_SAMPLER_SIGNATURE_SPLIT; -} - -void WgslShaderGenerator::emitDirectives(GenContext& context, ShaderStage& stage) const -{ - VkShaderGenerator::emitDirectives(context, stage); - // Add additional directives and #define statements here - // Example: emitLine("#define HW_SEPARATE_SAMPLERS", stage, false); - emitLineBreak(stage); -} - -// Called by CompoundNode::emitFunctionDefinition() -void WgslShaderGenerator::emitFunctionDefinitionParameter(const ShaderPort* shaderPort, bool isOutput, GenContext& context, ShaderStage& stage) const -{ - if (shaderPort->getType() == Type::FILENAME) - { - emitString("texture2D " + shaderPort->getVariable() + "_texture, sampler "+shaderPort->getVariable() + "_sampler", stage); - } - else - { - VkShaderGenerator::emitFunctionDefinitionParameter(shaderPort, isOutput, context, stage); - } -} - -// Called by SourceCodeNode::emitFunctionCall() -void WgslShaderGenerator::emitInput(const ShaderInput* input, GenContext& context, ShaderStage& stage) const -{ - if (input->getType() == Type::FILENAME) - { - emitString(getUpstreamResult(input, context)+"_texture, "+getUpstreamResult(input, context)+"_sampler", stage); - } - else - { - VkShaderGenerator::emitInput(input, context, stage); - } -} - -MATERIALX_NAMESPACE_END diff --git a/source/MaterialXGenGlsl/WgslShaderGenerator.h b/source/MaterialXGenGlsl/WgslShaderGenerator.h deleted file mode 100644 index af9141f440..0000000000 --- a/source/MaterialXGenGlsl/WgslShaderGenerator.h +++ /dev/null @@ -1,52 +0,0 @@ -// -// Copyright Contributors to the MaterialX Project -// SPDX-License-Identifier: Apache-2.0 -// - -#ifndef MATERIALX_WGSLSHADERGENERATOR_H -#define MATERIALX_WGSLSHADERGENERATOR_H - -/// @file -/// Vulkan GLSL shader generator flavor for WGSL - -#include -#include -#include - -MATERIALX_NAMESPACE_BEGIN - -using WgslShaderGeneratorPtr = shared_ptr; - -/// @class WgslShaderGenerator -/// WGSL Flavor of Vulkan GLSL shader generator -class MX_GENGLSL_API WgslShaderGenerator : public VkShaderGenerator -{ - public: - /// Constructor. - WgslShaderGenerator(TypeSystemPtr typeSystem); - - /// Creator function. - /// If a TypeSystem is not provided it will be created internally. - /// Optionally pass in an externally created TypeSystem here, - /// if you want to keep type descriptions alive after the lifetime - /// of the shader generator. - static ShaderGeneratorPtr create(TypeSystemPtr typeSystem = nullptr) - { - return std::make_shared(typeSystem ? typeSystem : TypeSystem::create()); - } - - void emitDirectives(GenContext& context, ShaderStage& stage) const override; - - const string& getLightDataTypevarString() const override { return LIGHTDATA_TYPEVAR_STRING; } - - void emitFunctionDefinitionParameter(const ShaderPort* shaderPort, bool isOutput, GenContext& context, ShaderStage& stage) const override; - - void emitInput(const ShaderInput* input, GenContext& context, ShaderStage& stage) const override; - - protected: - static const string LIGHTDATA_TYPEVAR_STRING; -}; - -MATERIALX_NAMESPACE_END - -#endif diff --git a/source/MaterialXGenGlsl/WgslSyntax.cpp b/source/MaterialXGenGlsl/WgslSyntax.cpp deleted file mode 100644 index d5d58dc014..0000000000 --- a/source/MaterialXGenGlsl/WgslSyntax.cpp +++ /dev/null @@ -1,192 +0,0 @@ -// -// Copyright Contributors to the MaterialX Project -// SPDX-License-Identifier: Apache-2.0 -// - -#include - -MATERIALX_NAMESPACE_BEGIN - -WgslSyntax::WgslSyntax(TypeSystemPtr typeSystem) : VkSyntax(typeSystem) -{ - // Add in WGSL specific keywords - registerReservedWords( { - // Keywords (https://www.w3.org/TR/WGSL/#keyword-summary) - "alias", - "break", - "case", - "const", - "const_assert", - "continue", - "continuing", - "default", - "diagnostic", - "discard", - "else", - "enable", - "false", - "fn", - "for", - "if", - "let", - "loop", - "override", - "requires", - "return", - "struct", - "switch", - "true", - "var", - "while", - // Reserved Words (https://www.w3.org/TR/WGSL/#reserved-words) - "NULL", - "Self", - "abstract", - "active", - "alignas", - "alignof", - "as", - "asm", - "asm_fragment", - "async", - "attribute", - "auto", - "await", - "become", - "cast", - "catch", - "class", - "co_await", - "co_return", - "co_yield", - "coherent", - "column_major", - "common", - "compile", - "compile_fragment", - "concept", - "const_cast", - "consteval", - "constexpr", - "constinit", - "crate", - "debugger", - "decltype", - "delete", - "demote", - "demote_to_helper", - "do", - "dynamic_cast", - "enum", - "explicit", - "export", - "extends", - "extern", - "external", - "fallthrough", - "filter", - "final", - "finally", - "friend", - "from", - "fxgroup", - "get", - "goto", - "groupshared", - "highp", - "impl", - "implements", - "import", - "inline", - "instanceof", - "interface", - "layout", - "lowp", - "macro", - "macro_rules", - "match", - "mediump", - "meta", - "mod", - "module", - "move", - "mut", - "mutable", - "namespace", - "new", - "nil", - "noexcept", - "noinline", - "nointerpolation", - "non_coherent", - "noncoherent", - "noperspective", - "null", - "nullptr", - "of", - "operator", - "package", - "packoffset", - "partition", - "pass", - "patch", - "pixelfragment", - "precise", - "precision", - "premerge", - "priv", - "protected", - "pub", - "public", - "readonly", - "ref", - "regardless", - "register", - "reinterpret_cast", - "require", - "resource", - "restrict", - "self", - "set", - "shared", - "sizeof", - "smooth", - "snorm", - "static", - "static_assert", - "static_cast", - "std", - "subroutine", - "super", - "target", - "template", - "this", - "thread_local", - "throw", - "trait", - "try", - "type", - "typedef", - "typeid", - "typename", - "typeof", - "union", - "unless", - "unorm", - "unsafe", - "unsized", - "use", - "using", - "varying", - "virtual", - "volatile", - "wgsl", - "where", - "with", - "writeonly", - "yield" - } ); -} - - -MATERIALX_NAMESPACE_END diff --git a/source/MaterialXGenGlsl/WgslSyntax.h b/source/MaterialXGenGlsl/WgslSyntax.h deleted file mode 100644 index 0e16f221bd..0000000000 --- a/source/MaterialXGenGlsl/WgslSyntax.h +++ /dev/null @@ -1,27 +0,0 @@ -// -// Copyright Contributors to the MaterialX Project -// SPDX-License-Identifier: Apache-2.0 -// - -#ifndef MATERIALX_WGSLSYNTAX_H -#define MATERIALX_WGSLSYNTAX_H - -/// @file -/// Vulkan GLSL syntax class for WGSL - -#include - -MATERIALX_NAMESPACE_BEGIN - -/// Syntax class for Wgsl GLSL -class MX_GENGLSL_API WgslSyntax : public VkSyntax -{ - public: - WgslSyntax(TypeSystemPtr typeSystem); - - static SyntaxPtr create(TypeSystemPtr typeSystem) { return std::make_shared(typeSystem); } -}; - -MATERIALX_NAMESPACE_END - -#endif diff --git a/source/MaterialXGenWgsl/CMakeLists.txt b/source/MaterialXGenWgsl/CMakeLists.txt new file mode 100644 index 0000000000..b1d4361039 --- /dev/null +++ b/source/MaterialXGenWgsl/CMakeLists.txt @@ -0,0 +1,104 @@ +file(GLOB_RECURSE materialx_source "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp") +file(GLOB_RECURSE materialx_headers "${CMAKE_CURRENT_SOURCE_DIR}/*.h*") + +mx_add_library(MaterialXGenWgsl + SOURCE_FILES + ${materialx_source} + HEADER_FILES + ${materialx_headers} + MTLX_MODULES + MaterialXGenHw + MaterialXCore + EXPORT_DEFINE + MATERIALX_GENWGSL_EXPORTS) + +# Optional build-time regeneration of the genwgsl shader-node library from genglsl. This runs the +# offline transpiler (tools/mxgenwgsl.py) over libraries/ into the build tree purely for +# validation: transpile or naga errors -- and any previously-generable node that regresses -- make +# the build fail. The generated genwgsl .wgsl node files are NOT committed; they are produced from +# genglsl (the single source of truth) by CI, which runs the transpiler with `--out libraries` to +# populate the runtime library. This target is a decoupled local validation aid only (output goes to +# the build dir, not the source tree). Requires a Python interpreter and the `naga` CLI. +if(MATERIALX_GENERATE_WGSL_LIBRARY) + if(MATERIALX_PYTHON_EXECUTABLE) + set(_wgsl_python "${MATERIALX_PYTHON_EXECUTABLE}") + else() + find_package(Python3 COMPONENTS Interpreter) + set(_wgsl_python "${Python3_EXECUTABLE}") + endif() + + # Locate the naga CLI. Precedence: an explicit -D/cache value, then the NAGA env var, then a + # search of PATH and the standard cargo bin directories. `cargo install naga-cli` lands naga in + # ~/.cargo/bin (or $CARGO_HOME/bin), which is frequently NOT on PATH in CI / IDE / GUI shells -- + # so a developer who installed naga still hits "not found" without these hints. + if(NOT MATERIALX_NAGA_EXECUTABLE AND DEFINED ENV{NAGA}) + set(MATERIALX_NAGA_EXECUTABLE "$ENV{NAGA}" CACHE FILEPATH + "Path to the naga CLI used to transpile/validate the genwgsl library." FORCE) + endif() + find_program(MATERIALX_NAGA_EXECUTABLE NAMES naga naga.exe + HINTS + "$ENV{CARGO_HOME}/bin" # custom cargo home (all platforms) + "$ENV{HOME}/.cargo/bin" # default on Linux / macOS + "$ENV{USERPROFILE}/.cargo/bin" # default on Windows + DOC "Path to the naga CLI used to transpile/validate the genwgsl library.") + + # If naga is still not found, obtain it via cargo. cargo is located EMSDK-style (mirrors + # MATERIALX_EMSDK_PATH): an explicit MATERIALX_CARGO_PATH, then $CARGO_HOME, then the standard + # cargo bin dirs, then PATH. cargo is assumed to already be installed -- we do NOT bootstrap + # Rust. When found, naga is built into the build tree at *build* time (not configure, so + # configure stays fast) and cached there across rebuilds. + set(_wgsl_install_naga OFF) + if(NOT MATERIALX_NAGA_EXECUTABLE) + find_program(MATERIALX_CARGO_EXECUTABLE NAMES cargo cargo.exe + HINTS + "${MATERIALX_CARGO_PATH}/bin" # explicit -DMATERIALX_CARGO_PATH + "$ENV{CARGO_HOME}/bin" # custom cargo home + "$ENV{HOME}/.cargo/bin" # default on Linux / macOS + "$ENV{USERPROFILE}/.cargo/bin" # default on Windows + DOC "Path to the Rust cargo executable used to install the naga CLI.") + if(MATERIALX_CARGO_EXECUTABLE) + if(CMAKE_HOST_WIN32) + set(_naga_exe "naga.exe") + else() + set(_naga_exe "naga") + endif() + set(_naga_root "${CMAKE_BINARY_DIR}/naga") + # Predicted install location -- the file may not exist yet; a plain set() (not + # find_program) so the generation target can reference it before it is built. + set(MATERIALX_NAGA_EXECUTABLE "${_naga_root}/bin/${_naga_exe}") + set(_wgsl_install_naga ON) + endif() + endif() + + if(_wgsl_python AND MATERIALX_NAGA_EXECUTABLE) + # Build-time, one-time cargo install of naga-cli (only when resolved to a cargo location). + # The custom-command OUTPUT is the naga binary, so it re-runs only when that file is missing. + if(_wgsl_install_naga) + add_custom_command(OUTPUT "${MATERIALX_NAGA_EXECUTABLE}" + COMMAND "${MATERIALX_CARGO_EXECUTABLE}" install naga-cli --root "${_naga_root}" + COMMENT "Installing naga-cli via cargo into ${_naga_root} (one-time; compiles from source)" + VERBATIM) + add_custom_target(MaterialXInstallNaga DEPENDS "${MATERIALX_NAGA_EXECUTABLE}") + endif() + + add_custom_target(MaterialXGenWgslLibrary ALL + COMMAND ${CMAKE_COMMAND} -E env "NAGA=${MATERIALX_NAGA_EXECUTABLE}" + "${_wgsl_python}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/mxgenwgsl.py" + --libraries "${PROJECT_SOURCE_DIR}/libraries" + --out "${CMAKE_BINARY_DIR}/genwgsl_generated" + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + COMMENT "Transpiling genglsl shader-node library to genwgsl (validation)" + VERBATIM) + if(_wgsl_install_naga) + add_dependencies(MaterialXGenWgslLibrary MaterialXInstallNaga) + endif() + else() + message(WARNING + "MATERIALX_GENERATE_WGSL_LIBRARY is ON but a prerequisite was not found " + "(Python='${_wgsl_python}', naga='${MATERIALX_NAGA_EXECUTABLE}', " + "cargo='${MATERIALX_CARGO_EXECUTABLE}'). Install naga (`cargo install naga-cli`) and point " + "-DMATERIALX_NAGA_EXECUTABLE / the NAGA env var at it, or install cargo and set " + "-DMATERIALX_CARGO_PATH to its home so naga can be built into the tree. " + "Skipping genwgsl library generation.") + endif() +endif() diff --git a/source/MaterialXGenWgsl/Export.h b/source/MaterialXGenWgsl/Export.h new file mode 100644 index 0000000000..212a958ca7 --- /dev/null +++ b/source/MaterialXGenWgsl/Export.h @@ -0,0 +1,22 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#ifndef MATERIALX_GENWGSL_EXPORT_H +#define MATERIALX_GENWGSL_EXPORT_H + +#include + +/// @file +/// Macros for declaring imported and exported symbols. + +#if defined(MATERIALX_GENWGSL_EXPORTS) + #define MX_GENWGSL_API MATERIALX_SYMBOL_EXPORT + #define MX_GENWGSL_EXTERN_TEMPLATE(...) MATERIALX_EXPORT_EXTERN_TEMPLATE(__VA_ARGS__) +#else + #define MX_GENWGSL_API MATERIALX_SYMBOL_IMPORT + #define MX_GENWGSL_EXTERN_TEMPLATE(...) MATERIALX_IMPORT_EXTERN_TEMPLATE(__VA_ARGS__) +#endif + +#endif diff --git a/source/MaterialXGenWgsl/Nodes/WgslCompoundNode.cpp b/source/MaterialXGenWgsl/Nodes/WgslCompoundNode.cpp new file mode 100644 index 0000000000..c46326c70e --- /dev/null +++ b/source/MaterialXGenWgsl/Nodes/WgslCompoundNode.cpp @@ -0,0 +1,150 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#include + +#include +#include +#include +#include + +MATERIALX_NAMESPACE_BEGIN + +ShaderNodeImplPtr WgslCompoundNode::create() +{ + return std::make_shared(); +} + +void WgslCompoundNode::emitFunctionDefinition(const ShaderNode& node, GenContext& context, ShaderStage& stage) const +{ + DEFINE_SHADER_STAGE(stage, Stage::PIXEL) + { + const ShaderGenerator& shadergen = context.getShaderGenerator(); + + // Emit function definitions for all child nodes. + shadergen.emitFunctionDefinitions(*_rootGraph, context, stage); + + shadergen.emitLineBegin(stage); + shadergen.emitString("fn " + _functionName + "(", stage); + + // Closure data parameter (emits a trailing ", " when present). + shadergen.emitClosureDataParameter(node, context, stage); + + // Thread vertex data into closure/surface-shader functions. + const bool needsVertexData = nodeOutputIsClosure(node); + if (needsVertexData) + { + const VariableBlock& vertexData = stage.getInputBlock(HW::VERTEX_DATA); + const string vdTypeName = vertexData.empty() ? string("VertexData") : vertexData.getName(); + shadergen.emitString(vertexData.getInstance() + ": " + vdTypeName + ", ", stage); + } + + string delim; + for (ShaderGraphInputSocket* inputSocket : _rootGraph->getInputSockets()) + { + shadergen.emitString(delim, stage); + shadergen.emitFunctionDefinitionParameter(inputSocket, false, context, stage); + delim = ", "; + } + + // Output parameters become ptr so the caller passes &outVar. + for (ShaderGraphOutputSocket* outputSocket : _rootGraph->getOutputSockets()) + { + const string typeName = shadergen.getSyntax().getTypeName(outputSocket->getType()); + shadergen.emitString(delim + outputSocket->getVariable() + ": ptr", stage); + delim = ", "; + } + + shadergen.emitString(")", stage); + shadergen.emitLineEnd(stage, false); + + shadergen.emitFunctionBodyBegin(*_rootGraph, context, stage); + + if (nodeOutputIsClosure(node)) + { + shadergen.emitFunctionCalls(*_rootGraph, context, stage, ShaderNode::Classification::TEXTURE); + for (ShaderGraphOutputSocket* outputSocket : _rootGraph->getOutputSockets()) + { + if (outputSocket->getConnection()) + { + const ShaderNode* upstream = outputSocket->getConnection()->getNode(); + if (upstream->getParent() == _rootGraph.get() && + (upstream->hasClassification(ShaderNode::Classification::CLOSURE) || + upstream->hasClassification(ShaderNode::Classification::SHADER) || + upstream->hasClassification(ShaderNode::Classification::MATERIAL))) + { + shadergen.emitFunctionCall(*upstream, context, stage); + } + } + } + } + else + { + shadergen.emitFunctionCalls(*_rootGraph, context, stage); + } + + // Write results through the output pointers. + for (ShaderGraphOutputSocket* outputSocket : _rootGraph->getOutputSockets()) + { + const string result = shadergen.getUpstreamResult(outputSocket, context); + shadergen.emitLine("(*" + outputSocket->getVariable() + ") = " + result, stage); + } + + shadergen.emitFunctionBodyEnd(*_rootGraph, context, stage); + } +} + +void WgslCompoundNode::emitFunctionCall(const ShaderNode& node, GenContext& context, ShaderStage& stage) const +{ + const ShaderGenerator& shadergen = context.getShaderGenerator(); + + DEFINE_SHADER_STAGE(stage, Stage::VERTEX) + { + shadergen.emitFunctionCalls(*_rootGraph, context, stage); + } + + DEFINE_SHADER_STAGE(stage, Stage::PIXEL) + { + if (nodeOutputIsClosure(node)) + { + shadergen.emitDependentFunctionCalls(node, context, stage, ShaderNode::Classification::CLOSURE); + } + + // Declare the output variables. + emitOutputVariables(node, context, stage); + + shadergen.emitLineBegin(stage); + shadergen.emitString(_functionName + "(", stage); + + // Closure data argument (emits trailing ", " when present). + shadergen.emitClosureDataArg(node, context, stage); + + // Pass the vertex data instance into closure/surface-shader functions. + if (nodeOutputIsClosure(node)) + { + const VariableBlock& vertexData = stage.getInputBlock(HW::VERTEX_DATA); + shadergen.emitString(vertexData.getInstance() + ", ", stage); + } + + string delim; + for (ShaderInput* input : node.getInputs()) + { + shadergen.emitString(delim, stage); + shadergen.emitInput(input, context, stage); + delim = ", "; + } + for (size_t i = 0; i < node.numOutputs(); ++i) + { + shadergen.emitString(delim, stage); + shadergen.emitOutput(node.getOutput(i), false, false, context, stage); + delim = ", "; + } + + shadergen.emitString(")", stage); + shadergen.emitLineEnd(stage); + } +} + +MATERIALX_NAMESPACE_END diff --git a/source/MaterialXGenWgsl/Nodes/WgslCompoundNode.h b/source/MaterialXGenWgsl/Nodes/WgslCompoundNode.h new file mode 100644 index 0000000000..72f5f79a30 --- /dev/null +++ b/source/MaterialXGenWgsl/Nodes/WgslCompoundNode.h @@ -0,0 +1,28 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#ifndef MATERIALX_WGSLCOMPOUNDNODE_H +#define MATERIALX_WGSLCOMPOUNDNODE_H + +#include + +#include + +MATERIALX_NAMESPACE_BEGIN + +/// Compound node that emits a WGSL function ("fn name(param: type, ...)"). +/// For closure/surface-shader compound nodes a `vd: VertexData` parameter is threaded so the +/// surface node body can access vertex data (normalWorld, positionWorld, ...). +class MX_GENWGSL_API WgslCompoundNode : public CompoundNode +{ + public: + static ShaderNodeImplPtr create(); + void emitFunctionDefinition(const ShaderNode& node, GenContext& context, ShaderStage& stage) const override; + void emitFunctionCall(const ShaderNode& node, GenContext& context, ShaderStage& stage) const override; +}; + +MATERIALX_NAMESPACE_END + +#endif diff --git a/source/MaterialXGenWgsl/Nodes/WgslLightNodes.cpp b/source/MaterialXGenWgsl/Nodes/WgslLightNodes.cpp new file mode 100644 index 0000000000..0573fc7183 --- /dev/null +++ b/source/MaterialXGenWgsl/Nodes/WgslLightNodes.cpp @@ -0,0 +1,128 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#include + +#include +#include +#include +#include +#include + +MATERIALX_NAMESPACE_BEGIN + +// ============== WgslLightSamplerNode ============== + +namespace +{ +const string WGSL_SAMPLE_LIGHTS_FUNC_SIGNATURE = "fn sampleLightSource(light: LightData, position: vec3f, result: ptr)"; +const string WGSL_NUM_LIGHTS_FUNC_SIGNATURE = "fn numActiveLightSources() -> i32"; +} + +WgslLightSamplerNode::WgslLightSamplerNode() +{ + _hash = std::hash{}(WGSL_SAMPLE_LIGHTS_FUNC_SIGNATURE); +} + +ShaderNodeImplPtr WgslLightSamplerNode::create() +{ + return std::make_shared(); +} + +void WgslLightSamplerNode::emitFunctionDefinition(const ShaderNode& node, GenContext& context, ShaderStage& stage) const +{ + DEFINE_SHADER_STAGE(stage, Stage::PIXEL) + { + const ShaderGenerator& shadergen = context.getShaderGenerator(); + const Syntax& syntax = shadergen.getSyntax(); + const string vec3_zero = syntax.getValue(Type::VECTOR3, HW::VEC3_ZERO); + + shadergen.emitLine(WGSL_SAMPLE_LIGHTS_FUNC_SIGNATURE, stage, false); + shadergen.emitFunctionBodyBegin(node, context, stage); + shadergen.emitLine("(*result).intensity = " + vec3_zero, stage); + shadergen.emitLine("(*result).direction = " + vec3_zero, stage); + + HwLightShadersPtr lightShaders = context.getUserData(HW::USER_DATA_LIGHT_SHADERS); + if (lightShaders) + { + string ifstatement = "if "; + for (const auto& it : lightShaders->get()) + { + shadergen.emitLine(ifstatement + "(light." + shadergen.getLightDataTypevarString() + " == " + std::to_string(it.first) + ")", stage, false); + shadergen.emitScopeBegin(stage); + shadergen.emitFunctionCall(*it.second, context, stage); + shadergen.emitScopeEnd(stage); + ifstatement = "else if "; + } + } + + shadergen.emitFunctionBodyEnd(node, context, stage); + } +} + +// ============== WgslLightCompoundNode ============== + +ShaderNodeImplPtr WgslLightCompoundNode::create() +{ + return std::make_shared(); +} + +void WgslLightCompoundNode::emitFunctionDefinition(const ShaderNode& /*node*/, GenContext& context, ShaderStage& stage) const +{ + DEFINE_SHADER_STAGE(stage, Stage::PIXEL) + { + const HwShaderGenerator& shadergen = static_cast(context.getShaderGenerator()); + const Syntax& syntax = shadergen.getSyntax(); + const string vec3_zero = syntax.getValue(Type::VECTOR3, HW::VEC3_ZERO); + + shadergen.emitFunctionDefinitions(*_rootGraph, context, stage); + + shadergen.emitLine("fn " + _functionName + "(light: LightData, position: vec3f, result: ptr)", stage, false); + + shadergen.emitFunctionBodyBegin(*_rootGraph, context, stage); + + shadergen.emitFunctionCalls(*_rootGraph, context, stage, ShaderNode::Classification::TEXTURE); + + shadergen.emitLine("var closureData: ClosureData = makeClosureData(CLOSURE_TYPE_EMISSION, " + vec3_zero + ", -L, light.direction, " + vec3_zero + ", 0.0)", stage); + shadergen.emitFunctionCalls(*_rootGraph, context, stage, ShaderNode::Classification::SHADER | ShaderNode::Classification::LIGHT); + + shadergen.emitFunctionBodyEnd(*_rootGraph, context, stage); + } +} + +void WgslLightCompoundNode::emitFunctionCall(const ShaderNode&, GenContext& context, ShaderStage& stage) const +{ + DEFINE_SHADER_STAGE(stage, Stage::PIXEL) + { + const ShaderGenerator& shadergen = context.getShaderGenerator(); + shadergen.emitLine(_functionName + "(light, position, result)", stage); + } +} + +// ============== WgslNumLightsNode ============== + +WgslNumLightsNode::WgslNumLightsNode() +{ + _hash = std::hash{}(WGSL_NUM_LIGHTS_FUNC_SIGNATURE); +} + +ShaderNodeImplPtr WgslNumLightsNode::create() +{ + return std::make_shared(); +} + +void WgslNumLightsNode::emitFunctionDefinition(const ShaderNode& node, GenContext& context, ShaderStage& stage) const +{ + DEFINE_SHADER_STAGE(stage, Stage::PIXEL) + { + const ShaderGenerator& shadergen = context.getShaderGenerator(); + shadergen.emitLine(WGSL_NUM_LIGHTS_FUNC_SIGNATURE, stage, false); + shadergen.emitFunctionBodyBegin(node, context, stage); + shadergen.emitLine("return min(" + HW::T_NUM_ACTIVE_LIGHT_SOURCES + ", " + HW::LIGHT_DATA_MAX_LIGHT_SOURCES + ")", stage); + shadergen.emitFunctionBodyEnd(node, context, stage); + } +} + +MATERIALX_NAMESPACE_END diff --git a/source/MaterialXGenWgsl/Nodes/WgslLightNodes.h b/source/MaterialXGenWgsl/Nodes/WgslLightNodes.h new file mode 100644 index 0000000000..5f2ee5e67c --- /dev/null +++ b/source/MaterialXGenWgsl/Nodes/WgslLightNodes.h @@ -0,0 +1,47 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#ifndef MATERIALX_WGSLLIGHTNODES_H +#define MATERIALX_WGSLLIGHTNODES_H + +#include + +#include +#include +#include + +MATERIALX_NAMESPACE_BEGIN + +/// Light sampler node emitting the WGSL signature: +/// fn sampleLightSource(light: LightData, position: vec3f, result: ptr). +class MX_GENWGSL_API WgslLightSamplerNode : public HwLightSamplerNode +{ + public: + WgslLightSamplerNode(); + static ShaderNodeImplPtr create(); + void emitFunctionDefinition(const ShaderNode& node, GenContext& context, ShaderStage& stage) const override; +}; + +/// Light compound node emitting a WGSL light function definition and call. +class MX_GENWGSL_API WgslLightCompoundNode : public HwLightCompoundNode +{ + public: + static ShaderNodeImplPtr create(); + void emitFunctionDefinition(const ShaderNode& node, GenContext& context, ShaderStage& stage) const override; + void emitFunctionCall(const ShaderNode& node, GenContext& context, ShaderStage& stage) const override; +}; + +/// Num-lights node emitting fn numActiveLightSources() -> i32. +class MX_GENWGSL_API WgslNumLightsNode : public HwNumLightsNode +{ + public: + WgslNumLightsNode(); + static ShaderNodeImplPtr create(); + void emitFunctionDefinition(const ShaderNode& node, GenContext& context, ShaderStage& stage) const override; +}; + +MATERIALX_NAMESPACE_END + +#endif diff --git a/source/MaterialXGenWgsl/Nodes/WgslMaterialNode.cpp b/source/MaterialXGenWgsl/Nodes/WgslMaterialNode.cpp new file mode 100644 index 0000000000..f088ddfdfb --- /dev/null +++ b/source/MaterialXGenWgsl/Nodes/WgslMaterialNode.cpp @@ -0,0 +1,54 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#include + +#include +#include +#include +#include + +MATERIALX_NAMESPACE_BEGIN + +ShaderNodeImplPtr WgslMaterialNode::create() +{ + return std::make_shared(); +} + +void WgslMaterialNode::emitFunctionCall(const ShaderNode& _node, GenContext& context, ShaderStage& stage) const +{ + DEFINE_SHADER_STAGE(stage, Stage::PIXEL) + { + ShaderNode& node = const_cast(_node); + ShaderInput* surfaceshaderInput = node.getInput(ShaderNode::SURFACESHADER); + + if (!surfaceshaderInput->getConnection()) + { + // Just declare the output variable with a default value. + emitOutputVariables(node, context, stage); + return; + } + + const ShaderGenerator& shadergen = context.getShaderGenerator(); + const Syntax& syntax = shadergen.getSyntax(); + + // Emit the function call for upstream surface shader. + const ShaderNode* surfaceshaderNode = surfaceshaderInput->getConnection()->getNode(); + if (surfaceshaderNode->isAGraph()) + { + emitOutputVariables(node, context, stage); + return; + } + shadergen.emitFunctionCall(*surfaceshaderNode, context, stage); + + // Assign this result to the material output variable (WGSL declaration order). + const ShaderOutput* output = node.getOutput(); + shadergen.emitLine("var " + output->getVariable() + ": " + syntax.getTypeName(output->getType()) + + " = " + surfaceshaderInput->getConnection()->getVariable(), + stage); + } +} + +MATERIALX_NAMESPACE_END diff --git a/source/MaterialXGenWgsl/Nodes/WgslMaterialNode.h b/source/MaterialXGenWgsl/Nodes/WgslMaterialNode.h new file mode 100644 index 0000000000..fc84ded797 --- /dev/null +++ b/source/MaterialXGenWgsl/Nodes/WgslMaterialNode.h @@ -0,0 +1,31 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#ifndef MATERIALX_WGSLMATERIALNODE_H +#define MATERIALX_WGSLMATERIALNODE_H + +#include + +#include + +MATERIALX_NAMESPACE_BEGIN + +/// @class WgslMaterialNode +/// WGSL material node implementation. +/// +/// Identical to MaterialNode except that the material output variable is declared +/// in WGSL order ("var : = ") instead of the C/GLSL order +/// (" = ") hardcoded in the shared MaterialNode. +class MX_GENWGSL_API WgslMaterialNode : public MaterialNode +{ + public: + static ShaderNodeImplPtr create(); + + void emitFunctionCall(const ShaderNode& node, GenContext& context, ShaderStage& stage) const override; +}; + +MATERIALX_NAMESPACE_END + +#endif diff --git a/source/MaterialXGenWgsl/Nodes/WgslSourceCodeNode.cpp b/source/MaterialXGenWgsl/Nodes/WgslSourceCodeNode.cpp new file mode 100644 index 0000000000..fa887dca37 --- /dev/null +++ b/source/MaterialXGenWgsl/Nodes/WgslSourceCodeNode.cpp @@ -0,0 +1,110 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#include + +#include +#include +#include +#include +#include + +MATERIALX_NAMESPACE_BEGIN + +namespace +{ + +// Matches the inline-expression markers used by the shared SourceCodeNode. +const string INLINE_VARIABLE_PREFIX("{{"); +const string INLINE_VARIABLE_SUFFIX("}}"); + +} // anonymous namespace + +ShaderNodeImplPtr WgslSourceCodeNode::create() +{ + return std::make_shared(); +} + +void WgslSourceCodeNode::emitFunctionCall(const ShaderNode& node, GenContext& context, ShaderStage& stage) const +{ + // Only the inlined path needs WGSL-specific handling for its constant + // temporaries; the ordinary function-call path defers to the base class, + // whose argument emission already routes through the WGSL generator's + // emitInput / emitOutput overrides. + if (!_inlined) + { + SourceCodeNode::emitFunctionCall(node, context, stage); + return; + } + + DEFINE_SHADER_STAGE(stage, Stage::PIXEL) + { + const ShaderGenerator& shadergen = context.getShaderGenerator(); + + if (nodeOutputIsClosure(node)) + { + // Emit calls for any closure dependencies upstream from this nodedef. + shadergen.emitDependentFunctionCalls(node, context, stage, ShaderNode::Classification::CLOSURE); + } + + size_t pos = 0; + size_t i = _functionSource.find(INLINE_VARIABLE_PREFIX); + StringSet variableNames; + StringVec code; + while (i != string::npos) + { + code.push_back(_functionSource.substr(pos, i - pos)); + + size_t j = _functionSource.find(INLINE_VARIABLE_SUFFIX, i + 2); + if (j == string::npos) + { + throw ExceptionShaderGenError("Malformed inline expression in implementation for node " + node.getName()); + } + + const string variable = _functionSource.substr(i + 2, j - i - 2); + const ShaderInput* input = node.getInput(variable); + if (!input) + { + throw ExceptionShaderGenError("Could not find an input named '" + variable + + "' on node '" + node.getName() + "'"); + } + + if (input->getConnection()) + { + code.push_back(shadergen.getUpstreamResult(input, context)); + } + else + { + string variableName = node.getName() + "_" + input->getName() + "_tmp"; + if (!variableNames.count(variableName)) + { + // Emit the constant temporary in WGSL declaration order via the + // generator's emitVariableDeclaration override ("const name: type = value"). + ShaderPort v(nullptr, input->getType(), variableName, input->getValue()); + shadergen.emitLineBegin(stage); + shadergen.emitVariableDeclaration(&v, shadergen.getSyntax().getConstantQualifier(), context, stage); + shadergen.emitLineEnd(stage); + variableNames.insert(variableName); + } + code.push_back(variableName); + } + + pos = j + 2; + i = _functionSource.find(INLINE_VARIABLE_PREFIX, pos); + } + code.push_back(_functionSource.substr(pos)); + + shadergen.emitLineBegin(stage); + shadergen.emitOutput(node.getOutput(), true, false, context, stage); + shadergen.emitString(" = ", stage); + for (const string& c : code) + { + shadergen.emitString(c, stage); + } + shadergen.emitLineEnd(stage); + } +} + +MATERIALX_NAMESPACE_END diff --git a/source/MaterialXGenWgsl/Nodes/WgslSourceCodeNode.h b/source/MaterialXGenWgsl/Nodes/WgslSourceCodeNode.h new file mode 100644 index 0000000000..fb46e7e9c0 --- /dev/null +++ b/source/MaterialXGenWgsl/Nodes/WgslSourceCodeNode.h @@ -0,0 +1,32 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#ifndef MATERIALX_WGSLSOURCECODENODE_H +#define MATERIALX_WGSLSOURCECODENODE_H + +#include + +#include + +MATERIALX_NAMESPACE_BEGIN + +/// @class WgslSourceCodeNode +/// WGSL source-code node implementation. +/// +/// Identical to SourceCodeNode except that the local constant temporaries emitted +/// for unconnected inputs of an inlined node use WGSL declaration order +/// ("const : = ") instead of the C/GLSL order +/// ("const = ") hardcoded in the shared SourceCodeNode. +class MX_GENWGSL_API WgslSourceCodeNode : public SourceCodeNode +{ + public: + static ShaderNodeImplPtr create(); + + void emitFunctionCall(const ShaderNode& node, GenContext& context, ShaderStage& stage) const override; +}; + +MATERIALX_NAMESPACE_END + +#endif diff --git a/source/MaterialXGenWgsl/Nodes/WgslSurfaceNode.cpp b/source/MaterialXGenWgsl/Nodes/WgslSurfaceNode.cpp new file mode 100644 index 0000000000..fe7019a220 --- /dev/null +++ b/source/MaterialXGenWgsl/Nodes/WgslSurfaceNode.cpp @@ -0,0 +1,218 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#include + +#include +#include +#include +#include + +MATERIALX_NAMESPACE_BEGIN + +ShaderNodeImplPtr WgslSurfaceNode::create() +{ + return std::make_shared(); +} + +void WgslSurfaceNode::emitFunctionCall(const ShaderNode& node, GenContext& context, ShaderStage& stage) const +{ + const HwShaderGenerator& shadergen = static_cast(context.getShaderGenerator()); + const Syntax& syntax = shadergen.getSyntax(); + + const string& vec3 = syntax.getTypeName(Type::VECTOR3); + const string vec3_zero = syntax.getValue(Type::VECTOR3, HW::VEC3_ZERO); + const string vec3_one = syntax.getValue(Type::VECTOR3, HW::VEC3_ONE); + + DEFINE_SHADER_STAGE(stage, Stage::VERTEX) + { + VariableBlock& vertexData = stage.getOutputBlock(HW::VERTEX_DATA); + const string prefix = shadergen.getVertexDataPrefix(vertexData); + ShaderPort* position = vertexData[HW::T_POSITION_WORLD]; + if (!position->isEmitted()) + { + position->setEmitted(); + shadergen.emitLine(prefix + position->getVariable() + " = hPositionWorld.xyz", stage); + } + ShaderPort* normal = vertexData[HW::T_NORMAL_WORLD]; + if (!normal->isEmitted()) + { + normal->setEmitted(); + shadergen.emitLine(prefix + normal->getVariable() + " = normalize((" + HW::T_WORLD_INVERSE_TRANSPOSE_MATRIX + " * " + syntax.getTypeName(Type::VECTOR4) + "(" + HW::T_IN_NORMAL + ", 0.0)).xyz)", stage); + } + if (context.getOptions().hwAmbientOcclusion) + { + ShaderPort* texcoord = vertexData[HW::T_TEXCOORD + "_0"]; + if (!texcoord->isEmitted()) + { + texcoord->setEmitted(); + shadergen.emitLine(prefix + texcoord->getVariable() + " = " + HW::T_IN_TEXCOORD + "_0", stage); + } + } + } + + DEFINE_SHADER_STAGE(stage, Stage::PIXEL) + { + VariableBlock& vertexData = stage.getInputBlock(HW::VERTEX_DATA); + const string prefix = shadergen.getVertexDataPrefix(vertexData); + + const ShaderOutput* output = node.getOutput(); + shadergen.emitLineBegin(stage); + shadergen.emitOutput(output, true, true, context, stage); + shadergen.emitLineEnd(stage); + + shadergen.emitScopeBegin(stage); + + shadergen.emitLine("var N: " + vec3 + " = normalize(" + prefix + HW::T_NORMAL_WORLD + ")", stage); + shadergen.emitLine("var V: " + vec3 + " = normalize(" + HW::T_VIEW_POSITION + " - " + prefix + HW::T_POSITION_WORLD + ")", stage); + shadergen.emitLine("var P: " + vec3 + " = " + prefix + HW::T_POSITION_WORLD, stage); + shadergen.emitLine("var L: " + vec3 + " = " + vec3_zero, stage); + shadergen.emitLine("var occlusion: f32 = 1.0", stage); + shadergen.emitLineBreak(stage); + + const string outColor = output->getVariable() + ".color"; + const string outTransparency = output->getVariable() + ".transparency"; + + const ShaderInput* bsdfInput = node.getInput("bsdf"); + if (const ShaderNode* bsdf = bsdfInput->getConnectedSibling()) + { + shadergen.emitLineBegin(stage); + shadergen.emitString("var surfaceOpacity: f32 = ", stage); + shadergen.emitInput(node.getInput("opacity"), context, stage); + shadergen.emitLineEnd(stage); + shadergen.emitLineBreak(stage); + + emitLightLoop(node, context, stage, outColor); + + shadergen.emitComment("Ambient occlusion", stage); + shadergen.emitLine("occlusion = 1.0", stage); + shadergen.emitLineBreak(stage); + + shadergen.emitComment("Add environment contribution", stage); + shadergen.emitScopeBegin(stage); + + if (bsdf->hasClassification(ShaderNode::Classification::BSDF_R)) + { + shadergen.emitLine("var closureData: ClosureData = makeClosureData(CLOSURE_TYPE_INDIRECT, L, V, N, P, occlusion)", stage); + shadergen.emitFunctionCall(*bsdf, context, stage); + } + else + { + shadergen.emitLineBegin(stage); + shadergen.emitOutput(bsdf->getOutput(), true, true, context, stage); + shadergen.emitLineEnd(stage); + } + + shadergen.emitLineBreak(stage); + shadergen.emitLine(outColor + " += occlusion * " + bsdf->getOutput()->getVariable() + ".response", stage); + shadergen.emitScopeEnd(stage); + shadergen.emitLineBreak(stage); + } + + const ShaderInput* edfInput = node.getInput("edf"); + if (const ShaderNode* edf = edfInput->getConnectedSibling()) + { + shadergen.emitComment("Add surface emission", stage); + shadergen.emitScopeBegin(stage); + + if (edf->hasClassification(ShaderNode::Classification::EDF)) + { + shadergen.emitLine("var closureData: ClosureData = makeClosureData(CLOSURE_TYPE_EMISSION, L, V, N, P, occlusion)", stage); + shadergen.emitFunctionCall(*edf, context, stage); + } + else + { + shadergen.emitLineBegin(stage); + shadergen.emitOutput(edf->getOutput(), true, true, context, stage); + shadergen.emitLineEnd(stage); + } + + shadergen.emitLine(outColor + " += " + edf->getOutput()->getVariable(), stage); + shadergen.emitScopeEnd(stage); + shadergen.emitLineBreak(stage); + } + + if (const ShaderNode* bsdf = bsdfInput->getConnectedSibling()) + { + shadergen.emitComment("Calculate the BSDF transmission for viewing direction", stage); + if (bsdf->hasClassification(ShaderNode::Classification::BSDF_T) || bsdf->hasClassification(ShaderNode::Classification::VDF)) + { + shadergen.emitLine("var closureData: ClosureData = makeClosureData(CLOSURE_TYPE_TRANSMISSION, L, V, N, P, occlusion)", stage); + shadergen.emitFunctionCall(*bsdf, context, stage); + } + else + { + shadergen.emitLineBegin(stage); + shadergen.emitOutput(bsdf->getOutput(), true, true, context, stage); + shadergen.emitLineEnd(stage); + } + + if (context.getOptions().hwTransmissionRenderMethod == TRANSMISSION_REFRACTION) + { + shadergen.emitLine(outColor + " += " + bsdf->getOutput()->getVariable() + ".response", stage); + } + else + { + shadergen.emitLine(outTransparency + " += " + bsdf->getOutput()->getVariable() + ".response", stage); + } + + shadergen.emitLineBreak(stage); + shadergen.emitComment("Compute and apply surface opacity", stage); + shadergen.emitScopeBegin(stage); + shadergen.emitLine(outColor + " *= surfaceOpacity", stage); + shadergen.emitLine(outTransparency + " = mix(" + vec3_one + ", " + outTransparency + ", surfaceOpacity)", stage); + shadergen.emitScopeEnd(stage); + } + + shadergen.emitScopeEnd(stage); + shadergen.emitLineBreak(stage); + } +} + +void WgslSurfaceNode::emitLightLoop(const ShaderNode& node, GenContext& context, ShaderStage& stage, const string& outColor) const +{ + if (context.getOptions().hwMaxActiveLightSources <= 0) + return; + + const HwShaderGenerator& shadergen = static_cast(context.getShaderGenerator()); + const VariableBlock& vertexData = stage.getInputBlock(HW::VERTEX_DATA); + const string prefix = shadergen.getVertexDataPrefix(vertexData); + + const ShaderInput* bsdfInput = node.getInput("bsdf"); + const ShaderNode* bsdf = bsdfInput->getConnectedSibling(); + + shadergen.emitComment("Light loop", stage); + shadergen.emitLine("var numLights: i32 = numActiveLightSources()", stage); + shadergen.emitLine("var lightShader: lightshader", stage); + shadergen.emitLine("for (var activeLightIndex: i32 = 0; activeLightIndex < numLights; activeLightIndex += 1)", stage, false); + + shadergen.emitScopeBegin(stage); + + shadergen.emitLine("sampleLightSource(" + HW::T_LIGHT_DATA_INSTANCE + "[activeLightIndex], " + prefix + HW::T_POSITION_WORLD + ", &lightShader)", stage); + shadergen.emitLine("L = lightShader.direction", stage); + shadergen.emitLineBreak(stage); + + shadergen.emitComment("Calculate the BSDF response for this light source", stage); + if (bsdf->hasClassification(ShaderNode::Classification::BSDF_R)) + { + shadergen.emitLine("var closureData: ClosureData = makeClosureData(CLOSURE_TYPE_REFLECTION, L, V, N, P, occlusion)", stage); + shadergen.emitFunctionCall(*bsdf, context, stage); + } + else + { + shadergen.emitLineBegin(stage); + shadergen.emitOutput(bsdf->getOutput(), true, true, context, stage); + shadergen.emitLineEnd(stage); + } + + shadergen.emitLineBreak(stage); + shadergen.emitComment("Accumulate the light's contribution", stage); + shadergen.emitLine(outColor + " += lightShader.intensity * " + bsdf->getOutput()->getVariable() + ".response", stage); + + shadergen.emitScopeEnd(stage); + shadergen.emitLineBreak(stage); +} + +MATERIALX_NAMESPACE_END diff --git a/source/MaterialXGenWgsl/Nodes/WgslSurfaceNode.h b/source/MaterialXGenWgsl/Nodes/WgslSurfaceNode.h new file mode 100644 index 0000000000..fcf1495ee0 --- /dev/null +++ b/source/MaterialXGenWgsl/Nodes/WgslSurfaceNode.h @@ -0,0 +1,26 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#ifndef MATERIALX_WGSLSURFACENODE_H +#define MATERIALX_WGSLSURFACENODE_H + +#include + +#include + +MATERIALX_NAMESPACE_BEGIN + +/// Surface node that emits native WGSL (var name: type = value, for (var i: i32 = ...; ...)). +class MX_GENWGSL_API WgslSurfaceNode : public HwSurfaceNode +{ + public: + static ShaderNodeImplPtr create(); + void emitFunctionCall(const ShaderNode& node, GenContext& context, ShaderStage& stage) const override; + void emitLightLoop(const ShaderNode& node, GenContext& context, ShaderStage& stage, const string& outColor) const override; +}; + +MATERIALX_NAMESPACE_END + +#endif diff --git a/source/MaterialXGenWgsl/README.md b/source/MaterialXGenWgsl/README.md new file mode 100644 index 0000000000..a92814a91e --- /dev/null +++ b/source/MaterialXGenWgsl/README.md @@ -0,0 +1,61 @@ +# MaterialXGenWgsl + +A native [WGSL](https://www.w3.org/TR/WGSL/) (WebGPU Shading Language) shader generator back-end for +MaterialX, registered under the `genwgsl` target. `WgslShaderGenerator` derives directly from +`HwShaderGenerator` (not the GLSL hierarchy) and emits standalone WGSL vertex + fragment shaders, +mirroring the structure of the `MaterialXGenMsl` / `MaterialXGenSlang` back-ends. It is gated behind +the `MATERIALX_BUILD_GEN_WGSL` CMake option (**OFF by default**: its node library is transpiled from +genglsl and is not committed, so enabling the target requires that library to be generated first — +see below). + +## What makes this back-end unusual + +Every other shader-gen back-end ships a fully hand-written node library. `genwgsl` does **not** — +its node library is a *hybrid*, and that is the main thing to understand before editing it: + +* **Most node `.wgsl` files are machine-generated** from their `genglsl` originals by the offline + transpiler in [`tools/`](tools/README.md). They are **not committed** — they are a derived build + artifact, transpiled from genglsl (the single source of truth) by CI, so drift between the GLSL and + WGSL libraries is impossible by construction. The generated files carry a + `// Generated from … do not edit` banner; do not hand-edit them, and do not commit them. +* **`lib/` helper files are machine-generated** from `genglsl/lib/` by the same transpiler (run + before node transpilation). All 22 `genglsl/lib` files transpile via naga (including prefilter + environment helpers). They are also **not committed**. +* **`mx_chiang_hair_bsdf` is hand-written** and intentionally *not* generated (naga limitation on + hair scattering helpers). It is listed in `EXPECTED_FALLBACK` in `tools/mxgenwgsl.py`. +* **Texture / image and light nodes are hand-written** too — the transpiler skips them (naga's GLSL + front-end has no sampler support, and light shaders use the dynamically generated `LightData`). + +The library lives in `libraries/{stdlib,pbrlib,lights}/genwgsl/` with the target defined in +`libraries/targets/genwgsl.mtlx`; node implementations are wired up via `*_genwgsl_impl.mtlx`. + +## Generating the library + +Because the generated node files are not committed, you must produce them before building the WGSL +target. CI does this automatically (installing the [`naga`](https://github.com/gfx-rs/wgpu/tree/trunk/naga) +CLI and running the transpiler) on the jobs that build or ship WGSL — the web viewer, the Python +sdist/wheels, and the tagged-release archives. Locally, populate the library in place with: + +``` +python source/MaterialXGenWgsl/tools/mxgenwgsl.py --libraries libraries --out libraries +``` + +then configure with `-DMATERIALX_BUILD_GEN_WGSL=ON`. The transpiler exits non-zero on any lib or +node failure outside `EXPECTED_FALLBACK` — so CI running it doubles as validation that a change +hasn't broken the WGSL target. See +[`tools/README.md`](tools/README.md) for the tool, its overload mapping table, and its lib-arity +self-validation. + +The separate `-DMATERIALX_GENERATE_WGSL_LIBRARY=ON` option adds a `MaterialXGenWgslLibrary` target +that re-transpiles into the *build tree* (not the source tree) as a local validation aid; it is +decoupled from `MATERIALX_BUILD_GEN_WGSL`. + +## Layout + +| Path | Contents | +| --- | --- | +| `WgslShaderGenerator.{h,cpp}` | The `genwgsl` shader generator (vertex + fragment WGSL). | +| `WgslSyntax.{h,cpp}` | WGSL type names and syntactic rules. | +| `WgslResourceBindingContext.{h,cpp}` | `@group`/`@binding` uniform, split texture/sampler, and structured light-data bindings. | +| `Nodes/` | C++ node implementations that emit WGSL dynamically. | +| `tools/` | The offline `genglsl`→`genwgsl` library transpiler (see its README). | diff --git a/source/MaterialXGenWgsl/WgslResourceBindingContext.cpp b/source/MaterialXGenWgsl/WgslResourceBindingContext.cpp new file mode 100644 index 0000000000..1c5c5faab0 --- /dev/null +++ b/source/MaterialXGenWgsl/WgslResourceBindingContext.cpp @@ -0,0 +1,129 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#include + +#include +#include +#include +#include + +MATERIALX_NAMESPACE_BEGIN + +WgslResourceBindingContext::WgslResourceBindingContext(size_t group) : + _group(group), + _binding(0) +{ +} + +void WgslResourceBindingContext::initialize() +{ + _binding = 0; +} + +void WgslResourceBindingContext::emitDirectives(GenContext&, ShaderStage&) +{ + // WGSL has no preprocessor directives for resource binding. +} + +void WgslResourceBindingContext::emitResourceBindings(GenContext& context, const VariableBlock& uniforms, ShaderStage& stage) +{ + const ShaderGenerator& generator = context.getShaderGenerator(); + const Syntax& syntax = generator.getSyntax(); + const string groupStr = std::to_string(_group); + + for (const ShaderPort* uniform : uniforms.getVariableOrder()) + { + const TypeDesc type = uniform->getType(); + + // Closure / shader types are internal MaterialX types, not CPU-supplied uniforms. + if (type.isClosure()) + { + continue; + } + + const string& name = uniform->getVariable(); + + if (type == Type::FILENAME) + { + // File textures split into a texture + sampler, each with its own binding. + generator.emitLine("@group(" + groupStr + ") @binding(" + std::to_string(_binding++) + + ") var " + name + "_texture: texture_2d", + stage); + generator.emitLine("@group(" + groupStr + ") @binding(" + std::to_string(_binding++) + + ") var " + name + "_sampler: sampler", + stage); + continue; + } + + // Value uniform: emit individually so node code references it by its plain name. + // WGSL: bool is not host-shareable in the uniform address space, so use u32. + string typeName = syntax.getTypeName(type); + if (type == Type::BOOLEAN) + { + typeName = "u32"; + } + if (type.isArray() && uniform->getValue()) + { + // array form. + typeName = "array<" + typeName + ", " + std::to_string(uniform->getValue()->asA>().size()) + ">"; + } + generator.emitLine("@group(" + groupStr + ") @binding(" + std::to_string(_binding++) + + ") var " + name + ": " + typeName, + stage); + } + generator.emitLineBreak(stage); +} + +void WgslResourceBindingContext::emitStructuredResourceBindings(GenContext& context, const VariableBlock& uniforms, + ShaderStage& stage, const string& structInstanceName, + const string& arraySuffix) +{ + const ShaderGenerator& generator = context.getShaderGenerator(); + const Syntax& syntax = generator.getSyntax(); + const string groupStr = std::to_string(_group); + + // Emit the element struct definition. + generator.emitLine("struct " + uniforms.getName() + " ", stage, false); + generator.emitScopeBegin(stage); + const auto& order = uniforms.getVariableOrder(); + for (size_t i = 0; i < order.size(); ++i) + { + const ShaderPort* port = order[i]; + string typeName = syntax.getTypeName(port->getType()); + if (port->getType() == Type::BOOLEAN) + { + typeName = "u32"; + } + if (port->getType().isArray() && port->getValue()) + { + typeName = "array<" + typeName + ", " + std::to_string(port->getValue()->asA>().size()) + ">"; + } + const string comma = (i + 1 < order.size()) ? "," : ""; + generator.emitLine(" " + port->getVariable() + ": " + typeName + comma, stage, false); + } + generator.emitScopeEnd(stage, false, false); + generator.emitLineBreak(stage); + + // Convert a GLSL-style "[N]" suffix into a WGSL array store type. + string storeType = uniforms.getName(); + if (!arraySuffix.empty() && arraySuffix.front() == '[' && arraySuffix.back() == ']') + { + const string count = arraySuffix.substr(1, arraySuffix.size() - 2); + storeType = "array<" + uniforms.getName() + ", " + count + ">"; + } + + // Bind the structured array (e.g. the light-data array) as read-only storage + // rather than uniform: the WGSL uniform address space requires array element + // strides to be a multiple of 16, which a minimal element struct (e.g. a single + // i32 light_type) does not satisfy. The storage address space relaxes this and + // is the idiomatic choice for variable-length light arrays. + generator.emitLine("@group(" + groupStr + ") @binding(" + std::to_string(_binding++) + + ") var " + structInstanceName + ": " + storeType, + stage); + generator.emitLineBreak(stage); +} + +MATERIALX_NAMESPACE_END diff --git a/source/MaterialXGenWgsl/WgslResourceBindingContext.h b/source/MaterialXGenWgsl/WgslResourceBindingContext.h new file mode 100644 index 0000000000..131a4a500f --- /dev/null +++ b/source/MaterialXGenWgsl/WgslResourceBindingContext.h @@ -0,0 +1,53 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#ifndef MATERIALX_WGSLRESOURCEBINDINGCONTEXT_H +#define MATERIALX_WGSLRESOURCEBINDINGCONTEXT_H + +/// @file +/// WGSL resource binding context for standalone shaders. + +#include + +#include + +MATERIALX_NAMESPACE_BEGIN + +using WgslResourceBindingContextPtr = shared_ptr; + +/// @class WgslResourceBindingContext +/// Emits standard WGSL resource bindings for a complete standalone shader. +/// +/// All resources are placed in a single bind group with a monotonically increasing +/// binding index: +/// - Value uniforms are emitted individually as `@group(g) @binding(n) var name: type;` +/// so node code can reference them by their plain variable names (no struct prefix). +/// - File textures are split into a `texture_2d` + `sampler` pair, each with its own binding. +/// - Light data is emitted as a uniform array of a `LightData` struct. +class MX_GENWGSL_API WgslResourceBindingContext : public HwResourceBindingContext +{ + public: + explicit WgslResourceBindingContext(size_t group); + + static WgslResourceBindingContextPtr create(size_t group = 0) + { + return std::make_shared(group); + } + + void initialize() override; + void emitDirectives(GenContext& context, ShaderStage& stage) override; + void emitResourceBindings(GenContext& context, const VariableBlock& uniforms, ShaderStage& stage) override; + void emitStructuredResourceBindings(GenContext& context, const VariableBlock& uniforms, + ShaderStage& stage, const string& structInstanceName, + const string& arraySuffix) override; + + protected: + size_t _group; + size_t _binding; +}; + +MATERIALX_NAMESPACE_END + +#endif diff --git a/source/MaterialXGenWgsl/WgslShaderGenerator.cpp b/source/MaterialXGenWgsl/WgslShaderGenerator.cpp new file mode 100644 index 0000000000..d1bc286a4e --- /dev/null +++ b/source/MaterialXGenWgsl/WgslShaderGenerator.cpp @@ -0,0 +1,779 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +MATERIALX_NAMESPACE_BEGIN + +const string WgslShaderGenerator::TARGET = "genwgsl"; +const string WgslShaderGenerator::VERSION = "1.0"; +const string WgslShaderGenerator::LIGHTDATA_TYPEVAR_STRING = "light_type"; + +namespace +{ + +// Name of the @builtin(position) member of the vertex-data struct. +const string WGSL_CLIP_POSITION = "clipPosition"; + +// User data to track emitted WGSL function names and prevent duplicate definitions. +class WgslEmittedFunctions : public GenUserData +{ + public: + std::set names; +}; +const string WGSL_EMITTED_FUNCTIONS = "WGSL_EMITTED_FUNCTIONS"; + +// Scan WGSL source for "fn NAME(" and collect the function names. +void trackEmittedFunctions(const string& source, std::set& names) +{ + size_t pos = 0; + while (pos < source.size()) + { + size_t fnPos = source.find("fn ", pos); + if (fnPos == string::npos) + break; + size_t nameStart = fnPos + 3; + size_t nameEnd = source.find_first_of("( \t\n", nameStart); + if (nameEnd != string::npos && nameEnd > nameStart) + { + names.insert(source.substr(nameStart, nameEnd - nameStart)); + } + pos = (nameEnd != string::npos) ? nameEnd : fnPos + 3; + } +} + +// Wrap a non-vec4 value in a vec4f for the final pixel output. +void toVec4Wgsl(const TypeDesc& type, string& variable) +{ + if (type.isFloat3()) + variable = "vec4f(" + variable + ", 1.0)"; + else if (type.isFloat2()) + variable = "vec4f(" + variable + ", 0.0, 1.0)"; + else if (type == Type::FLOAT || type == Type::INTEGER) + variable = "vec4f(" + variable + ", " + variable + ", " + variable + ", 1.0)"; + else if (type == Type::BSDF || type == Type::EDF) + variable = "vec4f(" + variable + ", 1.0)"; + else + variable = "vec4f(0.0, 0.0, 0.0, 1.0)"; +} + +} // anonymous namespace + +WgslShaderGenerator::WgslShaderGenerator(TypeSystemPtr typeSystem) : + HwShaderGenerator(typeSystem, WgslSyntax::create(typeSystem)) +{ + registerImplementations(TARGET); + + // WGSL splits each FILENAME uniform into a separate texture and sampler (see + // WgslResourceBindingContext), so the environment lookups take the texture and + // sampler as two arguments. Override the default combined-sampler tokens to the + // split "_texture" / "_sampler" forms emitted by the binding context. + // ($envRadianceSampler / $envIrradianceSampler are WGSL-specific companion tokens + // used by the genwgsl environment libraries; they have no HwConstants entry.) + _tokenSubstitutions[HW::T_ENV_RADIANCE] = HW::ENV_RADIANCE + "_texture"; + _tokenSubstitutions["$envRadianceSampler"] = HW::ENV_RADIANCE + "_sampler"; + _tokenSubstitutions[HW::T_ENV_IRRADIANCE] = HW::ENV_IRRADIANCE + "_texture"; + _tokenSubstitutions["$envIrradianceSampler"] = HW::ENV_IRRADIANCE + "_sampler"; + + _lightSamplingNodes.push_back(ShaderNode::create(nullptr, "numActiveLightSources", WgslNumLightsNode::create())); + _lightSamplingNodes.push_back(ShaderNode::create(nullptr, "sampleLightSource", WgslLightSamplerNode::create())); +} + +void WgslShaderGenerator::registerImplementations(const string& target) +{ + StringVec elementNames; + + registerImplementation("IM_position_vector3_" + target, HwPositionNode::create); + registerImplementation("IM_normal_vector3_" + target, HwNormalNode::create); + registerImplementation("IM_tangent_vector3_" + target, HwTangentNode::create); + registerImplementation("IM_bitangent_vector3_" + target, HwBitangentNode::create); + registerImplementation("IM_texcoord_vector2_" + target, HwTexCoordNode::create); + registerImplementation("IM_texcoord_vector3_" + target, HwTexCoordNode::create); + registerImplementation("IM_geomcolor_float_" + target, HwGeomColorNode::create); + registerImplementation("IM_geomcolor_color3_" + target, HwGeomColorNode::create); + registerImplementation("IM_geomcolor_color4_" + target, HwGeomColorNode::create); + + elementNames = { + "IM_geompropvalue_integer_" + target, + "IM_geompropvalue_float_" + target, + "IM_geompropvalue_color3_" + target, + "IM_geompropvalue_color4_" + target, + "IM_geompropvalue_vector2_" + target, + "IM_geompropvalue_vector3_" + target, + "IM_geompropvalue_vector4_" + target, + }; + registerImplementation(elementNames, HwGeomPropValueNode::create); + registerImplementation("IM_geompropvalue_boolean_" + target, HwGeomPropValueNodeAsUniform::create); + registerImplementation("IM_geompropvalue_string_" + target, HwGeomPropValueNodeAsUniform::create); + registerImplementation("IM_geompropvalue_filename_" + target, HwGeomPropValueNodeAsUniform::create); + + registerImplementation("IM_frame_float_" + target, HwFrameNode::create); + registerImplementation("IM_time_float_" + target, HwTimeNode::create); + registerImplementation("IM_viewdirection_vector3_" + target, HwViewDirectionNode::create); + + registerImplementation("IM_surface_" + target, WgslSurfaceNode::create); + registerImplementation("IM_light_" + target, HwLightNode::create); + registerImplementation("IM_point_light_" + target, HwLightShaderNode::create); + registerImplementation("IM_directional_light_" + target, HwLightShaderNode::create); + registerImplementation("IM_spot_light_" + target, HwLightShaderNode::create); + + registerImplementation("IM_transformpoint_vector3_" + target, HwTransformPointNode::create); + registerImplementation("IM_transformvector_vector3_" + target, HwTransformVectorNode::create); + registerImplementation("IM_transformnormal_vector3_" + target, HwTransformNormalNode::create); + + elementNames = { + "IM_image_float_" + target, + "IM_image_color3_" + target, + "IM_image_color4_" + target, + "IM_image_vector2_" + target, + "IM_image_vector3_" + target, + "IM_image_vector4_" + target, + }; + registerImplementation(elementNames, HwImageNode::create); + registerImplementation("IM_surfacematerial_" + target, WgslMaterialNode::create); + + registerImplementation("IM_dot_lightshader_" + target, HwLightShaderNode::create); +} + +ShaderNodeImplPtr WgslShaderGenerator::createShaderNodeImplForNodeGraph(const NodeGraph& nodegraph) const +{ + vector outputs = nodegraph.getActiveOutputs(); + if (outputs.empty()) + { + throw ExceptionShaderGenError("NodeGraph '" + nodegraph.getName() + "' has no outputs defined"); + } + + const TypeDesc outputType = _typeSystem->getType(outputs[0]->getType()); + if (outputType == Type::LIGHTSHADER) + { + return WgslLightCompoundNode::create(); + } + return WgslCompoundNode::create(); +} + +ShaderNodeImplPtr WgslShaderGenerator::createShaderNodeImplForImplementation(const Implementation&) const +{ + return WgslSourceCodeNode::create(); +} + +HwResourceBindingContextPtr WgslShaderGenerator::getResourceBindingContext(GenContext& context) const +{ + return context.getUserData(HW::USER_DATA_BINDING_CONTEXT); +} + +ShaderPtr WgslShaderGenerator::generate(const string& name, ElementPtr element, GenContext& context) const +{ + // Provide a default standalone resource binding context if none was supplied. + if (!getResourceBindingContext(context)) + { + auto bindingCtx = WgslResourceBindingContext::create(0); + context.pushUserData(HW::USER_DATA_BINDING_CONTEXT, std::static_pointer_cast(bindingCtx)); + } + if (HwResourceBindingContextPtr binding = getResourceBindingContext(context)) + { + binding->initialize(); + } + + ShaderPtr shader = createShader(name, element, context); + ScopedFloatFormatting fmt(Value::FloatFormatFixed); + + auto setDataSemantics = [](VariableBlock& vertexData) + { + for (size_t i = 0; i < vertexData.size(); ++i) + { + ShaderPort* port = vertexData[i]; + if (port->getSemantic().empty()) + port->setSemantic(port->getName()); + } + }; + + // Vertex stage. + ShaderStage& vs = shader->getStage(Stage::VERTEX); + setDataSemantics(vs.getOutputBlock(HW::VERTEX_DATA)); + emitVertexStage(shader->getGraph(), context, vs); + replaceTokens(_tokenSubstitutions, vs); + + // Pixel stage. + ShaderStage& ps = shader->getStage(Stage::PIXEL); + setDataSemantics(ps.getInputBlock(HW::VERTEX_DATA)); + emitPixelStage(shader->getGraph(), context, ps); + replaceTokens(_tokenSubstitutions, ps); + + return shader; +} + +string WgslShaderGenerator::getVertexDataPrefix(const VariableBlock& vertexData) const +{ + return vertexData.getInstance() + "."; +} + +bool WgslShaderGenerator::requiresLighting(const ShaderGraph& graph) const +{ + const bool isBsdf = graph.hasClassification(ShaderNode::Classification::BSDF); + const bool isLitSurfaceShader = graph.hasClassification(ShaderNode::Classification::SHADER) && + graph.hasClassification(ShaderNode::Classification::SURFACE) && + !graph.hasClassification(ShaderNode::Classification::UNLIT); + return isBsdf || isLitSurfaceShader; +} + +void WgslShaderGenerator::emitDirectives(GenContext&, ShaderStage&) const +{ + // WGSL has no #version or preprocessor directives. +} + +void WgslShaderGenerator::emitConstants(GenContext& context, ShaderStage& stage) const +{ + emitLine("const M_PI: f32 = 3.1415926535897932", stage); + emitLine("const M_FLOAT_EPS: f32 = 1e-6", stage); + emitLine("const CLOSURE_TYPE_DEFAULT: i32 = 0", stage); + emitLine("const CLOSURE_TYPE_REFLECTION: i32 = 1", stage); + emitLine("const CLOSURE_TYPE_TRANSMISSION: i32 = 2", stage); + emitLine("const CLOSURE_TYPE_INDIRECT: i32 = 3", stage); + emitLine("const CLOSURE_TYPE_EMISSION: i32 = 4", stage); + + const VariableBlock& constants = stage.getConstantBlock(); + for (size_t i = 0; i < constants.size(); ++i) + { + emitLineBegin(stage); + emitVariableDeclaration(constants[i], _syntax->getConstantQualifier(), context, stage); + emitLineEnd(stage); + } + emitLineBreak(stage); +} + +void WgslShaderGenerator::emitTypeDefinitions(GenContext& context, ShaderStage& stage) const +{ + // surfaceshader must be defined before mx_closure_type.wgsl (material = surfaceshader alias). + emitLine("struct surfaceshader {", stage, false); + emitLine(" color: vec3f,", stage, false); + emitLine(" transparency: vec3f,", stage, false); + emitLine("}", stage, false); + emitLineBreak(stage); + emitLine("struct displacementshader {", stage, false); + emitLine(" offset: vec3f,", stage, false); + emitLine(" scale: f32,", stage, false); + emitLine("}", stage, false); + emitLineBreak(stage); + emitLine("struct lightshader {", stage, false); + emitLine(" intensity: vec3f,", stage, false); + emitLine(" direction: vec3f,", stage, false); + emitLine("}", stage, false); + emitLineBreak(stage); + + // ClosureData, BSDF, VDF, EDF, material, FresnelData, makeClosureData. + emitLibraryInclude("pbrlib/genwgsl/lib/mx_closure_type.wgsl", context, stage); + emitLineBreak(stage); +} + +void WgslShaderGenerator::emitUniforms(GenContext& context, ShaderStage& stage) const +{ + HwResourceBindingContextPtr binding = getResourceBindingContext(context); + if (!binding) + return; + + for (const auto& it : stage.getUniformBlocks()) + { + const VariableBlock& uniforms = *it.second; + if (uniforms.empty() || uniforms.getName() == HW::LIGHT_DATA) + continue; + binding->emitResourceBindings(context, uniforms, stage); + } +} + +void WgslShaderGenerator::emitLightData(GenContext& context, ShaderStage& stage) const +{ + const VariableBlock& lightData = stage.getUniformBlock(HW::LIGHT_DATA); + if (lightData.empty()) + return; + + HwResourceBindingContextPtr binding = getResourceBindingContext(context); + if (!binding) + return; + + const string structArraySuffix = "[" + HW::LIGHT_DATA_MAX_LIGHT_SOURCES + "]"; + binding->emitStructuredResourceBindings(context, lightData, stage, lightData.getInstance(), structArraySuffix); +} + +void WgslShaderGenerator::emitSpecularEnvironment(GenContext& context, ShaderStage& stage) const +{ + const int specularMethod = context.getOptions().hwSpecularEnvironmentMethod; + if (specularMethod == SPECULAR_ENVIRONMENT_FIS) + { + emitLibraryInclude("pbrlib/genwgsl/lib/mx_environment_fis.wgsl", context, stage); + } + else if (specularMethod == SPECULAR_ENVIRONMENT_PREFILTER) + { + emitLibraryInclude("pbrlib/genwgsl/lib/mx_environment_prefilter.wgsl", context, stage); + } + else if (specularMethod == SPECULAR_ENVIRONMENT_NONE) + { + emitLibraryInclude("pbrlib/genwgsl/lib/mx_environment_none.wgsl", context, stage); + } + else + { + throw ExceptionShaderGenError("Invalid hardware specular environment method specified: '" + std::to_string(specularMethod) + "'"); + } + emitLineBreak(stage); +} + +void WgslShaderGenerator::emitTransmissionRender(GenContext& context, ShaderStage& stage) const +{ + const int transmissionMethod = context.getOptions().hwTransmissionRenderMethod; + if (transmissionMethod == TRANSMISSION_REFRACTION) + { + emitLibraryInclude("pbrlib/genwgsl/lib/mx_transmission_refract.wgsl", context, stage); + } + else if (transmissionMethod == TRANSMISSION_OPACITY) + { + emitLibraryInclude("pbrlib/genwgsl/lib/mx_transmission_opacity.wgsl", context, stage); + } + emitLineBreak(stage); +} + +void WgslShaderGenerator::emitLightFunctionDefinitions(const ShaderGraph& graph, GenContext& context, ShaderStage& stage) const +{ + DEFINE_SHADER_STAGE(stage, Stage::PIXEL) + { + if (requiresLighting(graph) && context.getOptions().hwMaxActiveLightSources > 0) + { + if (graph.hasClassification(ShaderNode::Classification::SHADER | ShaderNode::Classification::SURFACE)) + { + HwLightShadersPtr lightShaders = context.getUserData(HW::USER_DATA_LIGHT_SHADERS); + if (lightShaders) + { + for (const auto& it : lightShaders->get()) + emitFunctionDefinition(*it.second, context, stage); + } + for (const auto& it : _lightSamplingNodes) + emitFunctionDefinition(*it, context, stage); + } + } + } +} + +void WgslShaderGenerator::emitInputs(GenContext& /*context*/, ShaderStage& stage) const +{ + DEFINE_SHADER_STAGE(stage, Stage::VERTEX) + { + const VariableBlock& vertexInputs = stage.getInputBlock(HW::VERTEX_INPUTS); + if (!vertexInputs.empty()) + { + emitLine("struct " + vertexInputs.getName() + " ", stage, false); + emitScopeBegin(stage); + for (size_t i = 0; i < vertexInputs.size(); ++i) + { + const ShaderPort* p = vertexInputs[i]; + emitLine("@location(" + std::to_string(i) + ") " + p->getVariable() + ": " + + _syntax->getTypeName(p->getType()) + ",", + stage, false); + } + emitScopeEnd(stage, false, false); + emitLineBreak(stage); + } + } + + DEFINE_SHADER_STAGE(stage, Stage::PIXEL) + { + const VariableBlock& vertexData = stage.getInputBlock(HW::VERTEX_DATA); + emitLine("struct " + vertexData.getName() + " ", stage, false); + emitScopeBegin(stage); + emitLine("@builtin(position) " + WGSL_CLIP_POSITION + ": vec4f,", stage, false); + size_t loc = 0; + for (size_t i = 0; i < vertexData.size(); ++i) + { + const ShaderPort* p = vertexData[i]; + string attr = "@location(" + std::to_string(loc++) + ")"; + if (p->getType() == Type::INTEGER) + attr += " " + WgslSyntax::FLAT_QUALIFIER; + emitLine(attr + " " + p->getVariable() + ": " + _syntax->getTypeName(p->getType()) + ",", stage, false); + } + emitScopeEnd(stage, false, false); + emitLineBreak(stage); + } +} + +void WgslShaderGenerator::emitOutputs(GenContext& context, ShaderStage& stage) const +{ + DEFINE_SHADER_STAGE(stage, Stage::VERTEX) + { + const VariableBlock& vertexData = stage.getOutputBlock(HW::VERTEX_DATA); + emitLine("struct " + vertexData.getName() + " ", stage, false); + emitScopeBegin(stage); + emitLine("@builtin(position) " + WGSL_CLIP_POSITION + ": vec4f,", stage, false); + size_t loc = 0; + for (size_t i = 0; i < vertexData.size(); ++i) + { + const ShaderPort* p = vertexData[i]; + string attr = "@location(" + std::to_string(loc++) + ")"; + if (p->getType() == Type::INTEGER) + attr += " " + WgslSyntax::FLAT_QUALIFIER; + emitLine(attr + " " + p->getVariable() + ": " + _syntax->getTypeName(p->getType()) + ",", stage, false); + } + emitScopeEnd(stage, false, false); + emitLineBreak(stage); + } + + DEFINE_SHADER_STAGE(stage, Stage::PIXEL) + { + // Declare the pixel output variables as locals within fragmentMain. + const VariableBlock& outputs = stage.getOutputBlock(HW::PIXEL_OUTPUTS); + for (size_t i = 0; i < outputs.size(); ++i) + { + emitLineBegin(stage); + emitVariableDeclaration(outputs[i], EMPTY_STRING, context, stage, false); + emitLineEnd(stage); + } + emitLineBreak(stage); + } +} + +void WgslShaderGenerator::emitVertexStage(const ShaderGraph& graph, GenContext& context, ShaderStage& stage) const +{ + emitDirectives(context, stage); + emitConstants(context, stage); + emitUniforms(context, stage); + emitInputs(context, stage); + emitOutputs(context, stage); + + emitLibraryInclude("stdlib/genwgsl/lib/mx_math.wgsl", context, stage); + emitLineBreak(stage); + + emitFunctionDefinitions(graph, context, stage); + + const VariableBlock& vertexInputs = stage.getInputBlock(HW::VERTEX_INPUTS); + const VariableBlock& vertexData = stage.getOutputBlock(HW::VERTEX_DATA); + const string prefix = getVertexDataPrefix(vertexData); + + setFunctionName("vertexMain", stage); + emitLine("@vertex", stage, false); + emitLine("fn vertexMain(vsIn: " + vertexInputs.getName() + ") -> " + vertexData.getName() + " ", stage, false); + emitFunctionBodyBegin(graph, context, stage); + + // Alias the vertex inputs as locals so token-substituted node code resolves. + emitComment("Vertex input variables", stage); + for (size_t i = 0; i < vertexInputs.size(); ++i) + { + emitLine("var " + vertexInputs[i]->getVariable() + " = vsIn." + vertexInputs[i]->getVariable(), stage); + } + + emitLine("var hPositionWorld: vec4f = " + HW::T_WORLD_MATRIX + " * vec4f(" + HW::T_IN_POSITION + ", 1.0)", stage); + emitLine("var " + vertexData.getInstance() + ": " + vertexData.getName(), stage); + emitLine(prefix + WGSL_CLIP_POSITION + " = " + HW::T_VIEW_PROJECTION_MATRIX + " * hPositionWorld", stage); + + for (const ShaderNode* node : graph.getNodes()) + { + emitFunctionCall(*node, context, stage); + } + + emitLine("return " + vertexData.getInstance(), stage); + emitFunctionBodyEnd(graph, context, stage); +} + +void WgslShaderGenerator::emitPixelStage(const ShaderGraph& graph, GenContext& context, ShaderStage& stage) const +{ + emitDirectives(context, stage); + + // Type definitions (surfaceshader, displacementshader, lightshader, closure types). + emitTypeDefinitions(context, stage); + + // Constants. + emitConstants(context, stage); + + // Uniforms (material + private), excluding the light data block. + emitUniforms(context, stage); + + // Shared vertex-data input struct. + emitInputs(context, stage); + + // Common math helpers. + emitLibraryInclude("stdlib/genwgsl/lib/mx_math.wgsl", context, stage); + emitLineBreak(stage); + + const bool lighting = requiresLighting(graph); + const bool emitLightUniforms = lighting && context.getOptions().hwMaxActiveLightSources > 0; + + if (emitLightUniforms) + { + const unsigned int maxLights = std::max(1u, context.getOptions().hwMaxActiveLightSources); + emitLine("const " + HW::LIGHT_DATA_MAX_LIGHT_SOURCES + ": i32 = " + std::to_string(maxLights), stage); + emitLineBreak(stage); + } + + if (lighting) + { + emitSpecularEnvironment(context, stage); + emitTransmissionRender(context, stage); + } + + if (emitLightUniforms) + { + emitLightData(context, stage); + } + + _tokenSubstitutions[ShaderGenerator::T_FILE_TRANSFORM_UV] = + context.getOptions().fileTextureVerticalFlip ? "mx_transform_uv_vflip.wgsl" : "mx_transform_uv.wgsl"; + + emitLightFunctionDefinitions(graph, context, stage); + emitFunctionDefinitions(graph, context, stage); + + const ShaderGraphOutputSocket* outputSocket = graph.getOutputSocket(); + const VariableBlock& vertexData = stage.getInputBlock(HW::VERTEX_DATA); + + setFunctionName("fragmentMain", stage); + emitLine("@fragment", stage, false); + // The fragment entry takes only the interpolated vertex data. (Double-sided shading via + // @builtin(front_facing) is not currently emitted; a single struct parameter also keeps the + // entry consumable as a Three.js wgslFn by the TSL bridge in the web viewer.) + emitLine("fn fragmentMain(" + vertexData.getInstance() + ": " + vertexData.getName() + + ") -> @location(0) vec4f ", + stage, false); + emitFunctionBodyBegin(graph, context, stage); + + // Declare the pixel output local variable(s). + emitOutputs(context, stage); + + if (graph.hasClassification(ShaderNode::Classification::CLOSURE) && + !graph.hasClassification(ShaderNode::Classification::SHADER)) + { + // A bare closure with no surface: output black. + emitLine(outputSocket->getVariable() + " = vec4f(0.0, 0.0, 0.0, 1.0)", stage); + } + else + { + if (graph.hasClassification(ShaderNode::Classification::SHADER | ShaderNode::Classification::SURFACE)) + { + emitFunctionCalls(graph, context, stage, ShaderNode::Classification::TEXTURE); + for (ShaderGraphOutputSocket* socket : graph.getOutputSockets()) + { + if (socket->getConnection()) + { + const ShaderNode* upstream = socket->getConnection()->getNode(); + if (upstream->getParent() == &graph && + (upstream->hasClassification(ShaderNode::Classification::CLOSURE) || + upstream->hasClassification(ShaderNode::Classification::SHADER))) + { + emitFunctionCall(*upstream, context, stage); + } + } + } + } + else + { + emitFunctionCalls(graph, context, stage); + } + + const ShaderOutput* outputConnection = outputSocket->getConnection(); + if (outputConnection) + { + if (graph.hasClassification(ShaderNode::Classification::SURFACE)) + { + string outColor = outputConnection->getVariable() + ".color"; + const string outTransparency = outputConnection->getVariable() + ".transparency"; + if (context.getOptions().hwSrgbEncodeOutput) + { + outColor = "mx_srgb_encode(" + outColor + ")"; + } + if (context.getOptions().hwTransparency) + { + emitLine("var outAlpha: f32 = clamp(1.0 - dot(" + outTransparency + ", vec3f(0.3333, 0.3333, 0.3333)), 0.0, 1.0)", stage); + emitLine(outputSocket->getVariable() + " = vec4f(" + outColor + ", outAlpha)", stage); + emitLine("if (outAlpha < " + HW::T_ALPHA_THRESHOLD + ") ", stage, false); + emitScopeBegin(stage); + emitLine("discard", stage); + emitScopeEnd(stage); + } + else + { + emitLine(outputSocket->getVariable() + " = vec4f(" + outColor + ", 1.0)", stage); + } + } + else + { + string outValue = outputConnection->getVariable(); + if (context.getOptions().hwSrgbEncodeOutput && outputSocket->getType().isFloat3()) + { + outValue = "mx_srgb_encode(" + outValue + ")"; + } + if (!outputSocket->getType().isFloat4()) + { + toVec4Wgsl(outputSocket->getType(), outValue); + } + emitLine(outputSocket->getVariable() + " = " + outValue, stage); + } + } + else + { + string outputValue = outputSocket->getValue() ? + _syntax->getValue(outputSocket->getType(), *outputSocket->getValue()) : + _syntax->getDefaultValue(outputSocket->getType()); + if (!outputSocket->getType().isFloat4()) + { + string finalOutput = outputSocket->getVariable() + "_tmp"; + emitLine("var " + finalOutput + ": " + _syntax->getTypeName(outputSocket->getType()) + " = " + outputValue, stage); + toVec4Wgsl(outputSocket->getType(), finalOutput); + emitLine(outputSocket->getVariable() + " = " + finalOutput, stage); + } + else + { + emitLine(outputSocket->getVariable() + " = " + outputValue, stage); + } + } + } + + emitLine("return " + outputSocket->getVariable(), stage); + emitFunctionBodyEnd(graph, context, stage); +} + +// +// WGSL token mechanics +// + +void WgslShaderGenerator::emitVariableDeclaration(const ShaderPort* variable, const string& qualifier, + GenContext&, ShaderStage& stage, bool assignValue) const +{ + if (variable->getType() == Type::FILENAME) + { + emitString("var " + variable->getVariable() + ": texture_2d", stage); + return; + } + + string typeName = _syntax->getTypeName(variable->getType()); + if (variable->getType().isArray() && variable->getValue()) + typeName += _syntax->getArrayVariableSuffix(variable->getType(), *variable->getValue()); + + const bool isConst = (qualifier == _syntax->getConstantQualifier()); + string line = isConst ? "const " : "var "; + line += variable->getVariable() + ": " + typeName; + + if (assignValue && !isConst && qualifier == _syntax->getUniformQualifier()) + { + // Uniforms are not assigned inline. + emitString(line, stage); + return; + } + if (assignValue) + { + const string valueStr = variable->getValue() ? + _syntax->getValue(variable->getType(), *variable->getValue()) : + _syntax->getDefaultValue(variable->getType()); + if (!valueStr.empty()) + line += " = " + valueStr; + } + emitString(line, stage); +} + +void WgslShaderGenerator::emitOutput(const ShaderOutput* output, bool includeType, bool assignValue, GenContext& context, ShaderStage& stage) const +{ + if (includeType) + { + stage.addString("var " + output->getVariable() + ": " + _syntax->getTypeName(output->getType())); + } + else + { + // As a function-call argument an output is passed by pointer. + stage.addString(assignValue ? output->getVariable() : "&" + output->getVariable()); + } + + string suffix; + context.getOutputSuffix(output, suffix); + if (!suffix.empty()) + stage.addString(suffix); + + if (assignValue) + { + const string& value = _syntax->getDefaultValue(output->getType()); + if (!value.empty()) + stage.addString(" = " + value); + } +} + +void WgslShaderGenerator::emitFunctionDefinitionParameter(const ShaderPort* shaderPort, bool isOutput, GenContext&, ShaderStage& stage) const +{ + if (isOutput) + return; + if (shaderPort->getType() == Type::FILENAME) + { + const string& varName = shaderPort->getVariable(); + HwShaderGenerator::emitString(varName + "_texture: texture_2d, " + varName + "_sampler: sampler", stage); + return; + } + HwShaderGenerator::emitString(shaderPort->getVariable() + ": " + _syntax->getTypeName(shaderPort->getType()), stage); +} + +void WgslShaderGenerator::emitInput(const ShaderInput* input, GenContext& context, ShaderStage& stage) const +{ + if (input->getType() == Type::FILENAME) + { + const string base = getUpstreamResult(input, context); + HwShaderGenerator::emitString(base + "_texture, " + base + "_sampler", stage); + return; + } + if (input->getType() == Type::BOOLEAN) + { + // Boolean public uniforms are stored as u32 (bool is not host-shareable), so + // convert to bool at the use site. bool(bool) is an identity conversion, so this + // is also safe for boolean values originating from computed locals or literals. + HwShaderGenerator::emitString("bool(" + getUpstreamResult(input, context) + ")", stage); + return; + } + HwShaderGenerator::emitInput(input, context, stage); +} + +void WgslShaderGenerator::emitClosureDataParameter(const ShaderNode& node, GenContext&, ShaderStage& stage) const +{ + if (nodeNeedsClosureData(node)) + { + // WGSL parameter syntax: "closureData: ClosureData, ". + emitString(HW::CLOSURE_DATA_ARG + ": " + HW::CLOSURE_DATA_TYPE + ", ", stage); + } +} + +void WgslShaderGenerator::emitBlock(const string& str, const FilePath& sourceFilename, GenContext& context, ShaderStage& stage) const +{ + stage.addBlock(str, sourceFilename, context); + + if (sourceFilename.getExtension() == "wgsl" && !str.empty()) + { + auto data = context.getUserData(WGSL_EMITTED_FUNCTIONS); + if (!data) + { + data = std::make_shared(); + context.pushUserData(WGSL_EMITTED_FUNCTIONS, data); + } + trackEmittedFunctions(str, data->names); + } +} + +MATERIALX_NAMESPACE_END diff --git a/source/MaterialXGenWgsl/WgslShaderGenerator.h b/source/MaterialXGenWgsl/WgslShaderGenerator.h new file mode 100644 index 0000000000..6fb98e9439 --- /dev/null +++ b/source/MaterialXGenWgsl/WgslShaderGenerator.h @@ -0,0 +1,140 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#ifndef MATERIALX_WGSLSHADERGENERATOR_H +#define MATERIALX_WGSLSHADERGENERATOR_H + +/// @file +/// Native WGSL (WebGPU Shading Language) shader generator. +/// +/// Produces complete, standalone WGSL vertex + fragment shaders (with @vertex / @fragment +/// entry points and standard @group/@binding resources) from MaterialX node graphs, backed by +/// hand-written WGSL node implementation libraries under the `genwgsl` target. +/// +/// The class derives directly from HwShaderGenerator (not the GLSL hierarchy) and emits native +/// WGSL syntax. Its complete-shader structure mirrors SlangShaderGenerator, while the WGSL token +/// mechanics (var declarations, pointer outputs, split texture/sampler) follow the WebGPU spec. + +#include + +#include +#include + +MATERIALX_NAMESPACE_BEGIN + +using WgslShaderGeneratorPtr = shared_ptr; + +/// @class WgslShaderGenerator +/// Native WGSL shader generator for the `genwgsl` target. +class MX_GENWGSL_API WgslShaderGenerator : public HwShaderGenerator +{ + public: + WgslShaderGenerator(TypeSystemPtr typeSystem); + + /// Creator function. + static ShaderGeneratorPtr create(TypeSystemPtr typeSystem = nullptr) + { + return std::make_shared(typeSystem ? typeSystem : TypeSystem::create()); + } + + /// Generate a complete WGSL shader starting from the given element. + ShaderPtr generate(const string& name, ElementPtr element, GenContext& context) const override; + + /// Return the target identifier ("genwgsl"). + const string& getTarget() const override { return TARGET; } + + /// Return the WGSL version string. + virtual const string& getVersion() const { return VERSION; } + + /// "type" is a WGSL reserved word, so the LightData type member is renamed to "light_type". + const string& getLightDataTypevarString() const override { return LIGHTDATA_TYPEVAR_STRING; } + + /// Emit a WGSL variable declaration: "var name: type [= value]" / "const name: type = value". + void emitVariableDeclaration(const ShaderPort* variable, const string& qualifier, GenContext& context, ShaderStage& stage, + bool assignValue = true) const override; + + /// Emit a shader output, in WGSL syntax. As a function-call argument an output is passed by + /// pointer (&var) to match the ptr out-parameter convention. + void emitOutput(const ShaderOutput* output, bool includeType, bool assignValue, GenContext& context, ShaderStage& stage) const override; + + /// Emit a WGSL function parameter ("name: type"); output params are skipped (handled by callers). + /// For Type::FILENAME emit a texture_2d + sampler pair. + void emitFunctionDefinitionParameter(const ShaderPort* shaderPort, bool isOutput, GenContext& context, ShaderStage& stage) const override; + + /// Emit a function-call input argument. For Type::FILENAME emit (var_texture, var_sampler). + void emitInput(const ShaderInput* input, GenContext& context, ShaderStage& stage) const override; + + /// Emit the closure-data function parameter in WGSL syntax ("closureData: ClosureData, "). + void emitClosureDataParameter(const ShaderNode& node, GenContext& context, ShaderStage& stage) const override; + + static const string TARGET; + static const string VERSION; + + protected: + static const string LIGHTDATA_TYPEVAR_STRING; + + /// Emit the complete WGSL vertex stage (struct IO + @vertex entry point). + virtual void emitVertexStage(const ShaderGraph& graph, GenContext& context, ShaderStage& stage) const; + + /// Emit the complete WGSL pixel stage (type defs, uniforms, lighting, @fragment entry point). + virtual void emitPixelStage(const ShaderGraph& graph, GenContext& context, ShaderStage& stage) const; + + /// Emit the vertex input struct (VERTEX) or the shared vertex-data struct (PIXEL). + virtual void emitInputs(GenContext& context, ShaderStage& stage) const; + + /// Emit the shared vertex-data output struct (VERTEX) or the pixel output declaration (PIXEL). + virtual void emitOutputs(GenContext& context, ShaderStage& stage) const; + + /// Emit value/texture uniform resource bindings (skips the LightData block). + virtual void emitUniforms(GenContext& context, ShaderStage& stage) const; + + /// Emit the LightData struct + uniform array binding and MAX_LIGHT_SOURCES constant. + virtual void emitLightData(GenContext& context, ShaderStage& stage) const; + + /// Emit module-level constants (M_PI, M_FLOAT_EPS, CLOSURE_TYPE_*, graph constants). + virtual void emitConstants(GenContext& context, ShaderStage& stage) const; + + /// Emit closure / shader struct type definitions (surfaceshader, displacementshader, lightshader) + /// plus the closure type library (ClosureData, BSDF, FresnelData, makeClosureData). + void emitTypeDefinitions(GenContext& context, ShaderStage& stage) const override; + + /// Vertex data is accessed via the "vd." prefix. + string getVertexDataPrefix(const VariableBlock& vertexData) const override; + + bool requiresLighting(const ShaderGraph& graph) const override; + + /// Emit the specular environment library (FIS by default for standalone shaders). + virtual void emitSpecularEnvironment(GenContext& context, ShaderStage& stage) const; + + /// Emit the transmission rendering library (refraction or opacity). + virtual void emitTransmissionRender(GenContext& context, ShaderStage& stage) const; + + /// WGSL has no preprocessor directives; this is a structural no-op. + virtual void emitDirectives(GenContext& context, ShaderStage& stage) const; + + /// Emit light sampling function definitions when the graph requires lighting. + void emitLightFunctionDefinitions(const ShaderGraph& graph, GenContext& context, ShaderStage& stage) const; + + /// Override emitBlock to track emitted WGSL functions and avoid duplicate definitions. + void emitBlock(const string& str, const FilePath& sourceFilename, GenContext& context, ShaderStage& stage) const override; + + /// Use the WGSL-native compound / light-compound node implementations. + ShaderNodeImplPtr createShaderNodeImplForNodeGraph(const NodeGraph& nodegraph) const override; + + /// Use the WGSL-native source-code node implementation (WGSL declaration order). + ShaderNodeImplPtr createShaderNodeImplForImplementation(const Implementation& implementation) const override; + + /// Get the resource binding context from the GenContext. + HwResourceBindingContextPtr getResourceBindingContext(GenContext& context) const; + + /// Register the genwgsl node implementations. + void registerImplementations(const string& target); + + vector _lightSamplingNodes; +}; + +MATERIALX_NAMESPACE_END + +#endif diff --git a/source/MaterialXGenWgsl/WgslSyntax.cpp b/source/MaterialXGenWgsl/WgslSyntax.cpp new file mode 100644 index 0000000000..ae000aff65 --- /dev/null +++ b/source/MaterialXGenWgsl/WgslSyntax.cpp @@ -0,0 +1,398 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#include + +#include + +MATERIALX_NAMESPACE_BEGIN + +namespace +{ + +// WGSL has no string type, so MaterialX string/enum inputs are represented as integers. +class WgslStringTypeSyntax : public StringTypeSyntax +{ + public: + WgslStringTypeSyntax(const Syntax* parent) : + StringTypeSyntax(parent, "i32", "0", "0") { } + + string getValue(const Value& /*value*/, bool /*uniform*/) const override + { + return "0"; + } +}; + +// WGSL aggregate construction uses the type name as the constructor: vec3f(...), BSDF(...). +class WgslAggregateTypeSyntax : public AggregateTypeSyntax +{ + public: + WgslAggregateTypeSyntax(const Syntax* parent, const string& name, const string& defaultValue, const string& uniformDefaultValue, + const string& typeAlias = EMPTY_STRING, const string& typeDefinition = EMPTY_STRING, + const StringVec& members = EMPTY_MEMBERS) : + AggregateTypeSyntax(parent, name, defaultValue, uniformDefaultValue, typeAlias, typeDefinition, members) + { + } + + string getValue(const Value& value, bool uniform) const override + { + const string valueString = value.getValueString(); + if (uniform) + { + return valueString; + } + return valueString.empty() ? valueString : getName() + "(" + valueString + ")"; + } +}; + +} // anonymous namespace + +const string WgslSyntax::INPUT_QUALIFIER = ""; +const string WgslSyntax::OUTPUT_QUALIFIER = ""; +// WGSL uniforms are declared via var in the resource binding context, so there is +// no per-variable uniform keyword. +const string WgslSyntax::UNIFORM_QUALIFIER = "uniform"; +const string WgslSyntax::CONSTANT_QUALIFIER = "const"; +// Integer varyings need @interpolate(flat); handled where varyings are emitted. +const string WgslSyntax::FLAT_QUALIFIER = "@interpolate(flat)"; +const string WgslSyntax::SOURCE_FILE_EXTENSION = ".wgsl"; +const StringVec WgslSyntax::VEC2_MEMBERS = { ".x", ".y" }; +const StringVec WgslSyntax::VEC3_MEMBERS = { ".x", ".y", ".z" }; +const StringVec WgslSyntax::VEC4_MEMBERS = { ".x", ".y", ".z", ".w" }; + +// +// WgslSyntax methods +// + +WgslSyntax::WgslSyntax(TypeSystemPtr typeSystem) : + Syntax(typeSystem) +{ + // Register WGSL keywords and reserved words (https://www.w3.org/TR/WGSL/#keyword-summary). + registerReservedWords({ + // Keywords + "alias", "break", "case", "const", "const_assert", "continue", "continuing", + "default", "diagnostic", "discard", "else", "enable", "false", "fn", "for", + "if", "let", "loop", "override", "requires", "return", "struct", "switch", + "true", "var", "while", + // Reserved words + "NULL", "Self", "abstract", "active", "alignas", "alignof", "as", "asm", + "asm_fragment", "async", "attribute", "auto", "await", "become", "cast", "catch", + "class", "co_await", "co_return", "co_yield", "coherent", "column_major", "common", + "compile", "compile_fragment", "concept", "const_cast", "consteval", "constexpr", + "constinit", "crate", "debugger", "decltype", "delete", "demote", "demote_to_helper", + "do", "dynamic_cast", "enum", "explicit", "export", "extends", "extern", "external", + "fallthrough", "filter", "final", "finally", "friend", "from", "fxgroup", "get", + "goto", "groupshared", "highp", "impl", "implements", "import", "inline", "instanceof", + "interface", "layout", "lowp", "macro", "macro_rules", "match", "mediump", "meta", + "mod", "module", "move", "mut", "mutable", "namespace", "new", "nil", "noexcept", + "noinline", "nointerpolation", "non_coherent", "noncoherent", "noperspective", "null", + "nullptr", "of", "operator", "package", "packoffset", "partition", "pass", "patch", + "pixelfragment", "precise", "precision", "premerge", "priv", "protected", "pub", + "public", "readonly", "ref", "regardless", "register", "reinterpret_cast", "require", + "resource", "restrict", "self", "set", "shared", "sizeof", "smooth", "snorm", "static", + "static_assert", "static_cast", "std", "subroutine", "super", "target", "template", + "this", "thread_local", "throw", "trait", "try", "type", "typedef", "typeid", "typename", + "typeof", "union", "unless", "unorm", "unsafe", "unsized", "use", "using", "varying", + "virtual", "volatile", "wgsl", "where", "with", "writeonly", "yield", + // Common built-in type/intrinsic names that must not collide with generated variables. + "f32", "i32", "u32", "vec2f", "vec3f", "vec4f", "mat3x3f", "mat4x4f", "texture_2d", + "sampler", "mix", "select" }); + + // + // Register syntax handlers for each data type. + // + + registerTypeSyntax( + Type::FLOAT, + std::make_shared( + this, + "f32", + "0.0", + "0.0")); + + registerTypeSyntax( + Type::FLOATARRAY, + std::make_shared( + this, + "f32", + EMPTY_STRING, + EMPTY_STRING)); + + registerTypeSyntax( + Type::INTEGER, + std::make_shared( + this, + "i32", + "0", + "0")); + + registerTypeSyntax( + Type::INTEGERARRAY, + std::make_shared( + this, + "i32", + EMPTY_STRING, + EMPTY_STRING)); + + registerTypeSyntax( + Type::BOOLEAN, + std::make_shared( + this, + "bool", + "false", + "false")); + + registerTypeSyntax( + Type::COLOR3, + std::make_shared( + this, + "vec3f", + "vec3f(0.0)", + "0.0, 0.0, 0.0", + EMPTY_STRING, + EMPTY_STRING, + VEC3_MEMBERS)); + + registerTypeSyntax( + Type::COLOR4, + std::make_shared( + this, + "vec4f", + "vec4f(0.0)", + "0.0, 0.0, 0.0, 0.0", + EMPTY_STRING, + EMPTY_STRING, + VEC4_MEMBERS)); + + registerTypeSyntax( + Type::VECTOR2, + std::make_shared( + this, + "vec2f", + "vec2f(0.0)", + "0.0, 0.0", + EMPTY_STRING, + EMPTY_STRING, + VEC2_MEMBERS)); + + registerTypeSyntax( + Type::VECTOR3, + std::make_shared( + this, + "vec3f", + "vec3f(0.0)", + "0.0, 0.0, 0.0", + EMPTY_STRING, + EMPTY_STRING, + VEC3_MEMBERS)); + + registerTypeSyntax( + Type::VECTOR4, + std::make_shared( + this, + "vec4f", + "vec4f(0.0)", + "0.0, 0.0, 0.0, 0.0", + EMPTY_STRING, + EMPTY_STRING, + VEC4_MEMBERS)); + + registerTypeSyntax( + Type::MATRIX33, + std::make_shared( + this, + "mat3x3f", + "mat3x3f(1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0)", + "1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0")); + + registerTypeSyntax( + Type::MATRIX44, + std::make_shared( + this, + "mat4x4f", + "mat4x4f(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0)", + "1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0")); + + registerTypeSyntax( + Type::STRING, + std::make_shared(this)); + + // File textures are split into a texture_2d + sampler pair; the generator handles + // FILENAME specially in emitVariableDeclaration / emitFunctionDefinitionParameter / emitInput. + registerTypeSyntax( + Type::FILENAME, + std::make_shared( + this, + "texture_2d", + EMPTY_STRING, + EMPTY_STRING)); + + registerTypeSyntax( + Type::BSDF, + std::make_shared( + this, + "BSDF", + "BSDF(vec3f(0.0), vec3f(1.0))", + EMPTY_STRING, + EMPTY_STRING, + EMPTY_STRING)); + + registerTypeSyntax( + Type::EDF, + std::make_shared( + this, + "EDF", + "EDF(0.0)", + "0.0, 0.0, 0.0", + "vec3f", + EMPTY_STRING)); + + registerTypeSyntax( + Type::VDF, + std::make_shared( + this, + "VDF", + "VDF(vec3f(0.0), vec3f(1.0))", + EMPTY_STRING, + EMPTY_STRING, + EMPTY_STRING)); + + registerTypeSyntax( + Type::SURFACESHADER, + std::make_shared( + this, + "surfaceshader", + "surfaceshader(vec3f(0.0), vec3f(0.0))", + EMPTY_STRING, + EMPTY_STRING, + EMPTY_STRING)); + + registerTypeSyntax( + Type::VOLUMESHADER, + std::make_shared( + this, + "volumeshader", + "volumeshader(vec3f(0.0), vec3f(0.0))", + EMPTY_STRING, + EMPTY_STRING, + EMPTY_STRING)); + + registerTypeSyntax( + Type::DISPLACEMENTSHADER, + std::make_shared( + this, + "displacementshader", + "displacementshader(vec3f(0.0), 1.0)", + EMPTY_STRING, + EMPTY_STRING, + EMPTY_STRING)); + + registerTypeSyntax( + Type::LIGHTSHADER, + std::make_shared( + this, + "lightshader", + "lightshader(vec3f(0.0), vec3f(0.0))", + EMPTY_STRING, + EMPTY_STRING, + EMPTY_STRING)); + + registerTypeSyntax( + Type::MATERIAL, + std::make_shared( + this, + "material", + "material(vec3f(0.0), vec3f(0.0))", + EMPTY_STRING, + "surfaceshader", + EMPTY_STRING)); +} + +void WgslSyntax::makeValidName(string& name) const +{ + Syntax::makeValidName(name); + // WGSL identifiers cannot begin with a digit. + if (!name.empty() && std::isdigit(static_cast(name[0]))) + name = "v_" + name; +} + +bool WgslSyntax::remapEnumeration(const string& value, TypeDesc type, const string& enumNames, std::pair& result) const +{ + // Early out if not an enum input. + if (enumNames.empty()) + { + return false; + } + + // Don't convert already supported types. + if (type != Type::STRING) + { + return false; + } + + // Early out if no valid value provided. + if (value.empty()) + { + return false; + } + + // WGSL has no strings, so enum strings are remapped to an integer index. + result.first = Type::INTEGER; + result.second = nullptr; + + StringVec valueElemEnumsVec = splitString(enumNames, ","); + for (size_t i = 0; i < valueElemEnumsVec.size(); i++) + { + valueElemEnumsVec[i] = trimSpaces(valueElemEnumsVec[i]); + } + auto pos = std::find(valueElemEnumsVec.begin(), valueElemEnumsVec.end(), value); + if (pos == valueElemEnumsVec.end()) + { + throw ExceptionShaderGenError("Given value '" + value + "' is not a valid enum value for input."); + } + const int index = static_cast(std::distance(valueElemEnumsVec.begin(), pos)); + result.second = Value::createValue(index); + + return true; +} + +StructTypeSyntaxPtr WgslSyntax::createStructSyntax(const string& structTypeName, const string& defaultValue, + const string& uniformDefaultValue, const string& typeAlias, + const string& typeDefinition) const +{ + return std::make_shared( + this, + structTypeName, + defaultValue, + uniformDefaultValue, + typeAlias, + typeDefinition); +} + +string WgslStructTypeSyntax::getValue(const Value& value, bool /* uniform */) const +{ + const AggregateValue& aggValue = static_cast(value); + + string result = aggValue.getTypeString() + "("; + + string separator = ""; + for (const auto& memberValue : aggValue.getMembers()) + { + result += separator; + separator = ", "; + + const string& memberTypeName = memberValue->getTypeString(); + const TypeDesc memberTypeDesc = _parent->getType(memberTypeName); + + // Recursively use the syntax to generate the output, so nested structs are supported. + result += _parent->getValue(memberTypeDesc, *memberValue, true); + } + + result += ")"; + + return result; +} + +MATERIALX_NAMESPACE_END diff --git a/source/MaterialXGenWgsl/WgslSyntax.h b/source/MaterialXGenWgsl/WgslSyntax.h new file mode 100644 index 0000000000..8cbd4e0686 --- /dev/null +++ b/source/MaterialXGenWgsl/WgslSyntax.h @@ -0,0 +1,69 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#ifndef MATERIALX_WGSL_SYNTAX_H +#define MATERIALX_WGSL_SYNTAX_H + +/// @file +/// WGSL (WebGPU Shading Language) syntax class + +#include + +#include + +MATERIALX_NAMESPACE_BEGIN + +/// Syntax class for the WebGPU Shading Language (WGSL). +/// +/// Unlike the legacy GLSL-derived WGSL flavor, this class derives directly from the +/// base Syntax and registers native WGSL type names (f32, vec3f, mat4x4f, ...). It is +/// modeled on SlangSyntax, the sibling complete-shader, non-GLSL syntax class. +class MX_GENWGSL_API WgslSyntax : public Syntax +{ + public: + WgslSyntax(TypeSystemPtr typeSystem); + + static SyntaxPtr create(TypeSystemPtr typeSystem) { return std::make_shared(typeSystem); } + + const string& getInputQualifier() const override { return INPUT_QUALIFIER; } + const string& getOutputQualifier() const override { return OUTPUT_QUALIFIER; } + const string& getConstantQualifier() const override { return CONSTANT_QUALIFIER; }; + const string& getUniformQualifier() const override { return UNIFORM_QUALIFIER; }; + const string& getSourceFileExtension() const override { return SOURCE_FILE_EXTENSION; }; + + void makeValidName(string& name) const override; + + /// Given an input specification attempt to remap this to an enumeration which is accepted by + /// the shader generator. The enumeration may be converted to a different type than the input. + bool remapEnumeration(const string& value, TypeDesc type, const string& enumNames, std::pair& result) const override; + + StructTypeSyntaxPtr createStructSyntax(const string& structTypeName, const string& defaultValue, + const string& uniformDefaultValue, const string& typeAlias, + const string& typeDefinition) const override; + + static const string INPUT_QUALIFIER; + static const string OUTPUT_QUALIFIER; + static const string UNIFORM_QUALIFIER; + static const string CONSTANT_QUALIFIER; + static const string FLAT_QUALIFIER; + static const string SOURCE_FILE_EXTENSION; + + static const StringVec VEC2_MEMBERS; + static const StringVec VEC3_MEMBERS; + static const StringVec VEC4_MEMBERS; +}; + +/// Specialization of TypeSyntax for aggregate (struct) types in WGSL. +class MX_GENWGSL_API WgslStructTypeSyntax : public StructTypeSyntax +{ + public: + using StructTypeSyntax::StructTypeSyntax; + + string getValue(const Value& value, bool uniform) const override; +}; + +MATERIALX_NAMESPACE_END + +#endif diff --git a/source/MaterialXGenWgsl/tools/.gitignore b/source/MaterialXGenWgsl/tools/.gitignore new file mode 100644 index 0000000000..c18dd8d83c --- /dev/null +++ b/source/MaterialXGenWgsl/tools/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/source/MaterialXGenWgsl/tools/README.md b/source/MaterialXGenWgsl/tools/README.md new file mode 100644 index 0000000000..cac3c3b538 --- /dev/null +++ b/source/MaterialXGenWgsl/tools/README.md @@ -0,0 +1,235 @@ +# genwgsl library transpiler + +`mxgenwgsl.py` generates the MaterialX **genwgsl** WGSL node library from the existing +**genglsl** sources, so GLSL stays the single source of truth and the WGSL library never has to be +hand-ported. It transpiles: + +* **shader-node fragments** — `libraries/{stdlib,pbrlib,lights}/genglsl/mx_*.glsl` +* **`genglsl/lib/` helpers** — `libraries/{stdlib,pbrlib}/genglsl/lib/*.glsl` + +into their `genwgsl` equivalents. Only **hand-written** `.wgsl` are committed — the files listed in +[`skip_transpile.txt`](skip_transpile.txt) (the stdlib `lib/` dir, the sampler-bound pbrlib `lib/` +helpers, and the texture/light node shaders naga can't express). Every other `genwgsl/*.wgsl` is a +**derived artifact**: not committed, regenerated by CI before building/shipping WGSL and by you +locally while working on the WGSL target, and removed from an in-place tree with [`--clean`](#usage). +Re-running is idempotent — the output is deterministic (no timestamps; overload stubs indexed by +declaration order), so regenerating after a genglsl change just brings genwgsl back in sync. + +## Requirements + +* **Python 3** (standard library only for the core transpile). +* **[`naga`](https://github.com/gfx-rs/wgpu/tree/trunk/naga) CLI** (`naga-cli`, v29+). Install with + `cargo install naga-cli`. Resolved from `--naga `, then `$NAGA`, then `PATH`. +* **Optional: [`tree-sitter-language-pack`](https://pypi.org/project/tree-sitter-language-pack/)**, + for the readability cleanup pass (see [WGSL cleanup](#wgsl-cleanup)). It ships a precompiled WGSL + grammar (and pulls the `tree-sitter` runtime), so no Node or `tree-sitter-cli` is needed: + `pip install -r source/MaterialXGenWgsl/tools/requirements-transpile.txt`. If it is missing (e.g. + a Python version with no wheel, such as 3.9), the transpiler prints a single `INFO` line and still + runs, emitting (more verbose) naga output; the cleanup test self-skips. CI installs it best-effort. + +## Usage + +``` +# Regenerate the whole library in place (what CI runs, and the usual local command): +python source/MaterialXGenWgsl/tools/mxgenwgsl.py --libraries libraries --out libraries + +# Emit to a staging dir instead of overwriting the runtime library: +python source/MaterialXGenWgsl/tools/mxgenwgsl.py --libraries libraries --out build/genwgsl_generated + +# Iterate on specific files (node or lib stems): +python source/MaterialXGenWgsl/tools/mxgenwgsl.py --libraries libraries --only mx_noise3d_float mx_sheen_bsdf + +# Delete generated .wgsl from an in-place tree, keeping only the hand-written skip_transpile.txt set +# (needs no naga): +python source/MaterialXGenWgsl/tools/mxgenwgsl.py --libraries libraries --clean +``` + +`--out libraries` writes each generated file straight into `libraries//genwgsl/`, populating +the runtime library in place alongside the hand-written texture/light/`EXPECTED_FALLBACK` nodes. +Set `MTLX_DEBUG=1` to dump the failing GLSL fragment for any node that naga rejects (see +[Troubleshooting](#troubleshooting)). + +### Exit codes + +`0` on success. A non-zero exit means an **unexpected** failure — a regression such as a genglsl +change that broke a previously-generable node, a new naga error, or a scan-driven validation +failure. Nodes listed in `EXPECTED_FALLBACK` failing to transpile is normal and does **not** fail +the run. + +## Scope + +* **In:** standalone node `.glsl` files referenced by `file=` implementations in + stdlib/pbrlib/lights, and the `genglsl/lib/*.glsl` helpers they use. +* **Out (auto-skipped):** + * *Texture/image nodes* — the genglsl uses the generator tokens `$texSamplerSignature` / + `$texSamplerSampler2D`, substituted by the GLSL backend as one **combined** `sampler2D`. WGSL + needs **separate** `texture_2d` + `sampler` bindings and `textureSample*` calls, and naga's + GLSL frontend won't split the combined sampler — so any signature it emits is wrong. Skipped by + `SKIP_PATTERNS` (`image`, `hextiled`). + * *Light shaders* — take the dynamically-generated `LightData` struct the fragment context can't + supply (`SKIP_PATTERNS` `_light$`). + +There are currently no `EXPECTED_FALLBACK` nodes: everything else transpiles. (`mx_chiang_hair_bsdf` +was a fallback until its `isinf()` call was replaced by `mx_isinf` — naga rejects GLSL `isinf` with +`Unsupported relational function: IsInf`; the hand-written `mx_math.wgsl` implements `mx_isinf` as a +finite-magnitude check.) + +The result is a *reduced* library by design: generated nodes and `lib/` helpers for everything that +resolves cleanly against genglsl, and hand-written `.wgsl` for the rest. + +## How it works + +**Mental model.** naga does the actual GLSL→WGSL translation — the `naga` subprocess call is a +single line. Everything else in the script is two adapters around it: + +* a **pre-processor** that manufactures a complete, compilable shader naga will accept from an + incomplete node fragment, and +* a **post-processor** that reconciles naga's (correct but verbose) output with the conventions of + the hand-written genwgsl library. + +The tool does **not** reimplement transpilation. The regex/bracket-matching in the script is +deliberate string surgery, not a real parser — naga's own run is the correctness gate. + +**Execution order in `main()`:** + +0. **Preflight validation** — `validateOverloadCoverage` (every scanned overload resolves via + `mangle()`), `validateTokenCoverage` (every `$`-token in transpiled lib sources is declared in + `HwConstants.cpp` or has a naga stub in `NAGA_LIB_STUBS`), and, after the lib pass, + `validateLibNames` (every `mangle()` name exists in the real genwgsl lib). Any failure is a hard + error, so a gap between genglsl, the naming tables, and the genwgsl lib is caught loudly instead + of silently mis-emitting a name. +1. `transpileLibs()` — topological sort of `genglsl/lib/*.glsl`, per-function naga transpile (or the + sibling-body path for heavy intra-file call graphs), `LIB_PREAMBLE` + (`DIRECTIONAL_ALBEDO_METHOD 0`, etc.), `mangle()` for overload output names, texture stubs (via + `NAGA_LIB_STUBS`), and a special handler for `mx_closure_type` (`wgslClosurePreamble()` + aggregates closure structs from GLSL + `GlslSyntax.cpp`). +2. `buildWgslLibSymbols()` — reads the generated `lib/*.wgsl` from `--out` for arity validation. +3. The per-node loop (`transpile()`) — runs after the lib pass so nodes can `#include` the freshly + generated `genwgsl/lib/*.wgsl`. + +**Per-node pipeline:** + +1. **Pre-process** — a node fragment has no `main`, `#include`s helpers, carries MaterialX + `$`-tokens, and uses generator-emitted closure structs, so none of it is valid standalone GLSL. + Build a naga-parseable translation unit: shared `#define`/`const`/`struct` context + function + *prototypes* (so helper calls resolve) + closure structs (`BSDF`/`surfaceshader`, from + `glslClosurePreamble()`, parsed from `GlslSyntax.cpp`) + `$`-token→placeholder swaps + the one + function body + a dummy `main`. `parseFunctions` and `buildContext` exist only to synthesize that + context. +2. **Transpile** — run `naga --input-kind glsl --shader-stage frag` (which also validates). +3. **Post-process** — `mxwgslcleanup.py` collapses naga's param-copy shadows and restores GLSL + parameter names; then `mxgenwgsl.py` normalizes types, overload names, and float-literal + suffixes, and re-attaches comments. + +### WGSL cleanup + +`mxwgslcleanup.py` parses naga's WGSL output with a tree-sitter WGSL grammar and applies +conservative readability edits: collapse param-copy shadows, promote `var`→`let`, flatten else-if +chains, unwrap redundant compound blocks, etc. The grammar comes from the pip package +`tree-sitter-language-pack` (precompiled — no Node or `tree-sitter-cli`). It never blocks generation: +if the package is unavailable, the transpiler prints one `INFO` line and keeps the verbose naga +output for every function; if cleanup raises on an individual function while the grammar *is* +present, that function keeps its verbose output with a per-function `WARN`. + +### Overload handling + +WGSL has no function overloading, so two post-process passes bridge it: + +1. **`remapCalls` / `mangle()`** — overloaded GLSL helpers get a distinct type-suffixed name in the + genwgsl lib (`mx_square_f32`, `mx_perlin_noise_float_3d`, `mx_matrix_mul_mat4_vec4`, …). + `mangle(name, types, overloaded)` derives that name from a per-family `SUFFIX_SCHEME` rule + (default: suffix by the first parameter type), with an `EXCEPTIONS` table for irregular/semantic + names (the `mx_fresnel_schlick_*` / `mx_ggx_dir_albedo_*` forms, primary unsuffixed overloads). + The same function names lib definitions and rewrites node calls. naga numbers overload stubs `_N` + by prototype-declaration order, and prototypes are emitted sorted, so each call is rewritten to + the right lib function. +2. **`checkLibArity`** — parses the generated `lib/*.wgsl` signatures and rejects any node whose + helper-call arity disagrees, so it falls back to hand-written instead of emitting a shader that + fails to link. + +### naga identifier reconciliation (`denagaName`) + +naga's `Namer` (`proc/namer.rs`) rewrites identifiers deterministically: the first use of a +sanitized base gets a trailing `_` when it ends in a digit or is a keyword/builtin, and later +collisions get `_`. Because the transpiler re-emits some symbols itself (file-scope consts, the +`F0`/`F82`/`F90` closure-preamble struct fields, prototype names), `denagaName()` maps naga's +`NAME_` / `NAME_` references back to the clean declaration name. + +### Closure preambles and tokens (derived, not duplicated) + +Closure struct layouts are emitted from the same sources the C++ generators use: +`ClosureData`/`FresnelData` from `genglsl/lib/*.glsl`, `BSDF`/`VDF`/shader aliases from +`MaterialXGenGlsl/GlslSyntax.cpp` (`wgslClosurePreamble()` / `glslClosurePreamble()`). `FresnelData` +field names match GLSL (`thinfilm_thickness`, `thinfilm_ior` in `mx_microfacet_specular.glsl`). +MaterialX `$`-token *names* are validated against `HwConstants.cpp`; only the naga-parseable stub +*values* used during lib transpile live in `NAGA_LIB_STUBS` (plus `$closureDataConstructor`, read +from `HwConstants.cpp` at runtime). + +### Comment preservation + +naga discards comments, so the tool re-attaches them from genglsl: + +* *Doc / leading comments* (reliable) — the contiguous comment block directly above each function + (`parseFunctions` captures it) and a file-level top-of-file block are re-emitted verbatim (GLSL + and WGSL comment syntax are identical). +* *Inline body comments* (best-effort) — whole-line `//` comments inside a body are anchored to a + stable token of the following statement and re-inserted above the matching WGSL line + (`injectInlineComments`). Because naga reorders/inlines bodies this is approximate: unmatched + comments are dropped deterministically and insertion is always whole-line, so output is never + corrupted and re-runs stay byte-identical. Struct/const comments are still stripped. + +## Extending & maintaining + +Most of the tool is mechanical — the overload *keys* are discovered by scanning genglsl +(`buildContext`), so only the naming rules and known naga limitations are hand-maintained. When +genglsl changes, the pieces you may need to touch are: + +* **Overload naming** — add a family rule to `SUFFIX_SCHEME`, or an individual `(name, types)` entry + to `EXCEPTIONS`, when a new overloaded helper appears or an existing one is renamed. A `None` value + in `EXCEPTIONS` marks an overload the genwgsl lib deliberately doesn't provide (its callers stay + hand-written). `validateOverloadCoverage` / `validateLibNames` will fail loudly if a rule is + missing or produces a name absent from the lib. +* **Expected fallbacks** — `EXPECTED_FALLBACK` lists nodes intentionally left hand-written (naga + limitations). If a node there starts transpiling cleanly, the tool prints a `WARN`; remove it from + the set. If a *new* node cannot be generated, add it here (with a note on why) so the run stays + green. +* **`$`-tokens** — a new `$`-token used in a transpiled lib source needs a naga-parseable stub in + `NAGA_LIB_STUBS`; its name must also be declared in `HwConstants.cpp`. `validateTokenCoverage` + enforces both. +* **Hand-written `.wgsl`** — [`skip_transpile.txt`](skip_transpile.txt) lists every hand-maintained + genwgsl file (the stdlib `lib/` dir, the sampler-bound pbrlib `lib/` helpers naga can't express, + and the texture/light node shaders). The lib pass leaves any `lib/*.wgsl` listed there untouched, + and `--clean` keeps exactly this set while deleting everything else. When you add a new hand-written + file, add its repo-relative path here so it survives regeneration and `--clean`. + +## Troubleshooting + +* `FAIL ::: ` — naga rejected the synthesized fragment. Set `MTLX_DEBUG=1` and + re-run; the offending GLSL fragment is written next to the output as `_debug_*.frag` for inspection. +* `FAIL ... calls adapted/unmapped helper(s) ... (kept hand-written)` — the node calls a helper with + a signature the genwgsl lib doesn't provide (an `EXCEPTIONS` `None`, or an arity mismatch caught by + `checkLibArity`). Expected for nodes that must stay hand-written; add them to `EXPECTED_FALLBACK` + if permanent. +* `WARN mxwgslcleanup: ...` — the tree-sitter cleanup pass failed for one function; the verbose naga + output is kept. Non-fatal. +* `WARN : now transpiles cleanly` — a node in `EXPECTED_FALLBACK` no longer needs to be there. + +## Build integration (CMake / CI) + +Configure with `-DMATERIALX_GENERATE_WGSL_LIBRARY=ON` (requires `MATERIALX_BUILD_GEN_WGSL`, a Python +interpreter, and the `naga` CLI) to add a `MaterialXGenWgslLibrary` target that runs this tool over +`libraries/` on every build, emitting to `${CMAKE_BINARY_DIR}/genwgsl_generated`. Transpile/naga +errors and regressions then surface as **build errors**. The option defaults `OFF`, so ordinary +builds need neither Python nor naga. + +CMake finds naga from, in order: `-DMATERIALX_NAGA_EXECUTABLE=/path/to/naga`, the `NAGA` environment +variable, `PATH`, and the standard cargo bin directories (`$CARGO_HOME/bin`, `~/.cargo/bin`, +`%USERPROFILE%\.cargo\bin`) — so an existing `cargo install naga-cli` is picked up even when +`~/.cargo/bin` is not on `PATH`. If naga is still not found, CMake locates **cargo** the same way +EMSDK is located and builds naga into the build tree (`/naga`) via `cargo install naga-cli` at +build time (cached; recompiled only if the binary is missing). cargo must already be installed; +MaterialX does not bootstrap the Rust toolchain. If cargo cannot be found, generation is skipped with +a warning. + +CI runs the transpiler on every job that builds or ships WGSL (the web viewer, sdist/wheels, and the +tagged-release archives), so a change that breaks the WGSL target fails CI. diff --git a/source/MaterialXGenWgsl/tools/mxgenwgsl.py b/source/MaterialXGenWgsl/tools/mxgenwgsl.py new file mode 100644 index 0000000000..5895b55e4c --- /dev/null +++ b/source/MaterialXGenWgsl/tools/mxgenwgsl.py @@ -0,0 +1,1985 @@ +#!/usr/bin/env python +# +# Copyright Contributors to the MaterialX Project +# SPDX-License-Identifier: Apache-2.0 +# +''' +Transpile MaterialX genglsl node fragments and genglsl/lib helpers to genwgsl (WGSL). + +genglsl sources are not complete shaders: they use `#include`, `$`-tokens, and define functions +with no entry point. This tool wraps each target in a naga-parseable GLSL fragment shader, runs +`naga` (GLSL -> WGSL), then post-processes naga's SSA-style output into readable genwgsl fragments. + +Only hand-written `.wgsl` (the files listed in skip_transpile.txt) are committed; every other genwgsl +`.wgsl` is a derived artifact — not committed, regenerated by CI before build and removed from an +in-place tree with `--clean`. Re-running keeps genwgsl in sync with genglsl. + +Node transpilation (per mx_*.glsl function): + - ONE function body per naga call; lib context is *prototypes + #defines + structs*, not full + lib bodies — keeps parameter names clean and avoids `$`-tokens inside lib implementations. + - Skipped: image/hextiled texture nodes (combined GLSL sampler2D has no split WGSL + texture_2d+sampler equivalent) and light shaders (need the dynamic LightData struct). + - Expected fallbacks: none currently. EXPECTED_FALLBACK stays as the regression guard for future + naga limitations (mx_chiang_hair_bsdf left it once isinf was replaced by mx_isinf). + +Lib transpilation (`transpileLibs`, runs before nodes): + - Each genglsl/lib/*.glsl becomes genwgsl/lib/*.wgsl in topological #include order (22 files). + - Default: per-function transpile with full bodies of #included lib deps inlined, plus + LIB_PREAMBLE (generator-option pins), texture stubs, and mangle() overload renaming. + - Sibling-body path for mx_microfacet_specular, mx_microfacet_sheen, mx_flake, mx_noise + (heavy intra-file call graphs: transitive in-file callees + overload-aware topo sort). + - Special case: mx_closure_type (static struct preamble + transpiled makeClosureData). + - Lib transpile failures are hard errors (no hand-port fallback). + +Usage: + python mxgenwgsl.py --libraries libraries --out libraries + python mxgenwgsl.py --libraries libraries --out build/genwgsl_generated + python mxgenwgsl.py --libraries libraries --only mx_conductor_bsdf mx_math + python mxgenwgsl.py --libraries libraries --clean # delete generated .wgsl, keep skip_transpile.txt +''' + +import argparse +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +from mxwgslcleanup import cleanupAvailable, cleanupFunction, fnBase + +# The naga CLI (GLSL->WGSL). Resolved in main() via resolveNaga(): --naga arg, then $NAGA, then a +# `naga` on PATH; main() overwrites this global before any transpilation runs. +# Install with `cargo install naga-cli` (see https://github.com/gfx-rs/wgpu/tree/trunk/naga). +NAGA = "naga" + + +def resolveNaga(explicit=None): + '''Return a runnable naga command: explicit --naga, else $NAGA, else `naga` from PATH.''' + return explicit or os.environ.get("NAGA") or shutil.which("naga") or "naga" + + +def nagaVersion(naga): + '''Return naga's version string if it runs, else None (used to fail early with guidance).''' + try: + r = subprocess.run([naga, "--version"], capture_output=True, text=True) + return r.stdout.strip() if r.returncode == 0 else None + except OSError: + return None + + +def discoverLibs(libroot): + '''Library folders to process: every immediate subdirectory of --libraries that has a `genglsl` + node directory. Today that is stdlib/pbrlib/lights (bxdf is nodegraph-based, nprlib/targets have + no genglsl node fragments), but auto-discovering means new or downstream libraries are picked up + without editing this tool. Sorted for stable output ordering.''' + libroot = Path(libroot) + if not libroot.is_dir(): + return [] + return sorted(p.name for p in libroot.iterdir() if (p / "genglsl").is_dir()) + + +# Node shaders that can't be transpiled and stay hand-written, matched by file-stem regex. Two +# root causes: +# * Texture nodes -- the genglsl uses the generator tokens $texSamplerSignature/$texSamplerSampler2D +# (see mx_image_*.glsl, mx_hextiledimage.glsl), which the GLSL backend substitutes as ONE combined +# `sampler2D`. WGSL instead needs SEPARATE `texture_2d` + `sampler` params and textureSample* +# calls (cf. the hand-written mx_hextiledimage.wgsl). naga's GLSL frontend models the combined +# sampler and won't split it, so any signature it emits is wrong -- skip rather than mis-emit. +# * Light shaders -- take the dynamically-generated `LightData` struct the fragment context can't +# supply, so they can't be assembled into a standalone naga-parseable shader. +SKIP_PATTERNS = [re.compile(p) for p in ( + r"image", # texture nodes: combined sampler2D has no split texture_2d+sampler equivalent + r"hextiled", # texture nodes (same combined-sampler limitation) + r"_light$", # light shaders take the dynamically-generated LightData struct +)] + +# Nodes expected to fail transpile and stay hand-written, by file stem. A failure listed here is not +# a build error; only an *unexpected* failure (a previously-generable node that broke, e.g. after a +# genglsl change) sets a non-zero exit. If a node here starts transpiling cleanly, the tool warns so +# it can be removed. Currently empty -- every node either transpiles or is skipped outright by +# SKIP_PATTERNS. (mx_chiang_hair_bsdf used to live here: naga rejected its isinf() call until it was +# replaced with mx_isinf -- see libraries/stdlib/genwgsl/lib/mx_math.wgsl.) +EXPECTED_FALLBACK = set() + +# Hand-written genwgsl .wgsl files that must never be transpiled, listed in skip_transpile.txt +# (next to this script). Covers both whole hand-maintained lib dirs and individual sampler-bound +# helpers naga's GLSL frontend cannot express (environment radiance/prefilter, shadow-map lookups), +# plus the SKIP_PATTERNS node shaders so `--clean` retains them. Entries are repo-relative paths; +# keyed here as libroot-relative POSIX (e.g. stdlib/genwgsl/lib/mx_math.wgsl). +DO_NOT_TRANSPILE_FILE = Path(__file__).with_name("skip_transpile.txt") +_doNotTranspile = None + + +def loadDoNotTranspile(): + '''Set of hand-written genwgsl .wgsl files (libroot-relative POSIX paths) that must never be + transpiled or removed by --clean. Parsed once from skip_transpile.txt; a leading + `libraries/` component is stripped so the keys are anchored at --libraries.''' + global _doNotTranspile + if _doNotTranspile is None: + keep = set() + for raw in DO_NOT_TRANSPILE_FILE.read_text(encoding="utf-8").splitlines(): + line = raw.split("#", 1)[0].strip().replace("\\", "/").lstrip("/") + if not line: + continue + if line.startswith("libraries/"): + line = line[len("libraries/"):] + keep.add(line) + _doNotTranspile = keep + return _doNotTranspile + + +def isHandWrittenLib(lib, stem): + '''True if genwgsl/lib/.wgsl for this library is hand-written (in skip_transpile.txt) + and must be left untouched rather than transpiled from its genglsl source.''' + return f"{lib}/genwgsl/lib/{stem}.wgsl" in loadDoNotTranspile() + +TYPE_FIXUPS = [ + (re.compile(r"\bvec([234])"), r"vec\1f"), + (re.compile(r"\bvec([234])"), r"vec\1i"), + (re.compile(r"\bvec([234])"), r"vec\1u"), + (re.compile(r"\bmat([234])x\1"), r"mat\1x\1f"), +] + +# ----------------------------------------------------------------------------- +# overload name mangling +# ----------------------------------------------------------------------------- +# +# WGSL has no function overloading, so each GLSL overload needs a distinct genwgsl name. The *keys* +# (name + parameter types) are discovered by scanning genglsl (buildContext's `overloaded` set), +# so they are never hand-listed. Only the *naming* is specified here, and only for the families +# whose hand-written stdlib names don't follow the default first-parameter-type rule: +# +# * SUFFIX_SCHEME - families with a regular, rule-based suffix (keyed by base name). +# * EXCEPTIONS - individual (name, types) whose genwgsl name is semantic/irregular, or a +# primary overload that stays unsuffixed. A None value means "intentionally +# unsupported" (a node calling it stays hand-written). +# +# mangle() is the single source of truth for both emitting lib definitions (wgslFnName) and +# rewriting node calls (remapCalls). The generated names are validated against the real genwgsl +# lib after generation (buildWgslLibSymbols / checkLibArity), so a wrong rule fails loudly. + +def _suffixToken(t): + '''GLSL type -> the token used in a genwgsl overload suffix (float -> f32, else the GLSL name).''' + return "f32" if t == "float" else t + + +def _firstTypeSuffix(types): + '''`_`, e.g. mx_square(vec3) -> _vec3, mx_bilerp(float,...) -> _f32.''' + return "_" + _suffixToken(types[0]) + + +def _allTypesSuffix(types): + '''`__...` for every param, e.g. mx_matrix_mul(vec2,mat2) -> _vec2_mat2.''' + return "".join("_" + _suffixToken(t) for t in types) + + +def _vecDimSuffix(types): + '''`_2d`/`_3d` from the first vector param's dimension (mx_perlin_noise_float(vec3) -> _3d).''' + for t in types: + m = re.match(r"u?vec([234])$", t) + if m: + return "_" + m.group(1) + "d" + raise ValueError(f"vec_dim scheme: no vector parameter in {types}") + + +def _gradientDimSuffix(types): + '''`_2d`/`_3d` from coordinate count (hash + N coords): 3 params -> _2d, 4 params -> _3d.''' + return "_" + str(len(types) - 1) + "d" + + +def _cellposDimSuffix(types): + '''`_2d`/`_3d` from cell-position arg count: 5 params -> _2d, 7 params -> _3d.''' + return "_" + str((len(types) - 1) // 2) + "d" + + +def _argcountISuffix(types): + '''`_i` from parameter count, e.g. mx_hash_int(int,int) -> _i2.''' + return "_i" + str(len(types)) + + +# Base name -> suffix scheme. Overloaded families not listed use _firstTypeSuffix. Every entry +# is a GLSL-overloaded helper; the scheme reproduces the hand-written stdlib naming exactly. +SUFFIX_SCHEME = { + "mx_square": _firstTypeSuffix, + "mx_cell_noise_float": _firstTypeSuffix, + "mx_cell_noise_vec3": _firstTypeSuffix, + "mx_bilerp": _firstTypeSuffix, + "mx_trilerp": _firstTypeSuffix, + "mx_gradient_scale2d": _firstTypeSuffix, + "mx_gradient_scale3d": _firstTypeSuffix, + "mx_f0_to_ior": _firstTypeSuffix, # vec3 form; the float form is a primary EXCEPTION below + "mx_matrix_mul": _allTypesSuffix, + "mx_perlin_noise_float": _vecDimSuffix, + "mx_perlin_noise_vec3": _vecDimSuffix, + "mx_worley_noise_float": _vecDimSuffix, + "mx_worley_noise_vec2": _vecDimSuffix, + "mx_worley_noise_vec3": _vecDimSuffix, + "mx_worley_distance": _vecDimSuffix, + "mx_gradient_float": _gradientDimSuffix, + "mx_gradient_vec3": _gradientDimSuffix, + "mx_worley_cell_position": _cellposDimSuffix, + "mx_hash_int": _argcountISuffix, + "mx_hash_vec3": _argcountISuffix, +} + +# Individual overloads whose genwgsl name is semantic/irregular or a primary unsuffixed form. A +# None value marks a signature the genwgsl lib deliberately doesn't provide (node stays +# hand-written). Keyed exactly as buildContext extracts (name, GLSL param types). +EXCEPTIONS = { + ("mx_f0_to_ior", ("float",)): "mx_f0_to_ior", + + ("mx_fresnel_schlick", ("float", "float")): "mx_fresnel_schlick_f32", + ("mx_fresnel_schlick", ("float", "vec3")): "mx_fresnel_schlick_vec3", + ("mx_fresnel_schlick", ("float", "float", "float")): "mx_fresnel_schlick_f32_f90", + ("mx_fresnel_schlick", ("float", "vec3", "vec3")): "mx_fresnel_schlick_vec3_f90", + ("mx_fresnel_schlick", ("float", "float", "float", "float")): "mx_fresnel_schlick_f32_exp", + ("mx_fresnel_schlick", ("float", "vec3", "vec3", "float")): "mx_fresnel_schlick_vec3_exp", + + ("mx_ggx_dir_albedo", ("float", "float", "vec3", "vec3")): "mx_ggx_dir_albedo", + ("mx_ggx_dir_albedo", ("float", "float", "float", "float")): "mx_ggx_dir_albedo_scalar", + ("mx_ggx_dir_albedo", ("float", "float", "FresnelData")): "mx_ggx_dir_albedo_fresnel", +} + +# Bases mangle() can resolve (drives call remapping). Every such base is GLSL-overloaded. +SUPPORTED_BASES = set(SUFFIX_SCHEME) | {b for (b, _t) in EXCEPTIONS} + + +def _isMapped(base, types): + '''True if (base, types) has an explicit or scheme-derived genwgsl name.''' + return (base, types) in EXCEPTIONS or base in SUFFIX_SCHEME + + +def mangle(name, types, overloaded): + '''Return the genwgsl name for a GLSL overload, or None if intentionally unsupported. + + Resolution order: an explicit EXCEPTIONS entry, then the family's SUFFIX_SCHEME rule, else the + name is returned unchanged (non-overloaded / not a renamed helper). An overloaded base with no + scheme and no exception for this signature is unsupported (None), so the caller stays + hand-written -- matching the previous CALL_MAP 'missing key' behavior.''' + key = (name, types) + if key in EXCEPTIONS: + return EXCEPTIONS[key] + scheme = SUFFIX_SCHEME.get(name) + if scheme is not None: + return name + scheme(types) + if name in overloaded: + return None + return name + + +# ----------------------------------------------------------------------------- +# lib transpilation tables +# ----------------------------------------------------------------------------- + +# Lib files where per-function transpile without in-file siblings fails because helpers call +# each other in the same file. These use transpileLibFileSiblings (transitive in-file +# callee bodies + overload-aware topo sort) instead of one-function-at-a-time. +LIB_USE_SIBLING_BODIES = { + "mx_microfacet_specular", "mx_microfacet_sheen", "mx_flake", "mx_noise", +} + +# Pin preprocessor branches to match current genwgsl behavior. Method 0 = analytic directional +# albedo (table/MC paths in microfacet libs are excluded from the WGSL port). +LIB_PREAMBLE = """ +#define DIRECTIONAL_ALBEDO_METHOD 0 +#define AIRY_FRESNEL_ITERATIONS 2 +""" + +# naga's GLSL frontend rejects `uniform sampler2D` and real texture ops in lib fragments. +# These stubs let the shader parse; only the function bodies we extract matter for output. +TEXTURE_STUB_PREAMBLE = """ +vec3 mtlx_tex_lookup_rgb(vec2 uv, float lod) { return vec3(0.0); } +vec2 mtlx_tex_lookup_rg(vec2 uv) { return vec2(0.0); } +float mtlx_tex_lookup_b(vec2 uv) { return 0.0; } +float mtlx_tex_size_x() { return 256.0; } +""" + +# naga's GLSL frontend requires a staged shader with an entry point; every wrapped fragment ends +# with this do-nothing fragment `main`. naga keeps the non-entry function bodies we actually want. +FRAG_MAIN_EPILOGUE = "\nlayout(location=0) out vec4 mtlx_o;\nvoid main() { mtlx_o = vec4(0.0); }\n" + +# MaterialX $-tokens are not valid GLSL identifiers. Lib transpile expands a subset to +# naga-parseable GLSL stubs (NAGA_LIB_STUBS) before calling naga. Token *names* are validated +# against HwConstants.cpp (loadHwTokenNames); stub *values* are transpiler policy only. +# +# Replaces the former monolithic LIB_TOKEN_FIXUPS table: names come from the C++ generator, +# values stay here because naga needs parseable placeholders (sampler stubs, literal defaults). +NAGA_LIB_STUBS = { + "$texSamplerSignature": "sampler2D mtlx_tex_sampler", + "$texSamplerSampler2D": "mtlx_tex_sampler", + "$albedoTable": "mtlx_albedo_table", + "$albedoTableSize": "vec2(256.0)", + "$envRadianceSamples": "16", + "$envRadianceMips": "8", + "$envMatrix": "mat4(1.0)", + "$envRadiance": "mtlx_env_radiance_stub", + "$envIrradiance": "mtlx_env_irradiance_stub", + "$envLightIntensity": "vec3(1.0)", + "$envPrefilterMip": "0.0", + "$envRadianceSampler2D": "mtlx_tex_sampler", + "$refractionTwoSided": "false", +} + +# Struct types emitted centrally into mx_closure_type.wgsl (and skipped when transpiling other +# lib files). surfaceshader is emitted by WgslShaderGenerator::emitTypeDefinitions, not here. +# Layouts are derived from genglsl + GlslSyntax.cpp (wgslClosurePreamble), not hand-listed. +LIB_PREAMBLE_STRUCTS = {"surfaceshader", "ClosureData", "BSDF", "VDF", "FresnelData"} + +# Aggregate/shader struct names parsed from MaterialXGenGlsl/GlslSyntax.cpp definition strings. +# Used by glslClosurePreamble (node context) and wgslClosurePreamble (BSDF/VDF only). +_GLSL_SYNTAX_STRUCT_NAMES = ( + "BSDF", "VDF", "surfaceshader", "volumeshader", "displacementshader", "lightshader", +) + +# Set by main() before transpilation; used for preamble/token stub resolution. +_ACTIVE_LIBROOT = None + +# Cached preambles keyed by resolved libroot path (replaces hand-written CLOSURE_PREAMBLE / +# LIB_STRUCT_PREAMBLE tables). +_WGSL_CLOSURE_PREAMBLE = {} +_GLSL_CLOSURE_PREAMBLE = {} + +GLSL_TO_WGSL_TYPE = { + # Used by transpileGlslStructs/consts when emitting WGSL struct/const preamble for lib files. + "float": "f32", "int": "i32", "bool": "bool", + "vec2": "vec2f", "vec3": "vec3f", "vec4": "vec4f", + "mat2": "mat2x2f", "mat3": "mat3x3f", "mat4": "mat4x4f", + "mat3x3": "mat3x3f", "mat2x2": "mat2x2f", "mat4x4": "mat4x4f", +} + + +# ----------------------------------------------------------------------------- +# parsing +# ----------------------------------------------------------------------------- + +# These two scanners do depth-aware bracket matching so we can carve functions and argument lists +# out of source text without a full parser. They are the foundation the rest of the parsing relies +# on -- naga gives us no AST, so everything here is string surgery over balanced brackets. + +def _matchBrace(text, i): + '''Given index of an opening '{', return index just past the matching '}'.''' + depth = 0 + while i < len(text): + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + if depth == 0: + return i + 1 + i += 1 + return len(text) + + +def _matchParen(text, i): + '''Given index of an opening '(', return the index OF the matching ')' (-1 if unbalanced). + + Note the asymmetry with _matchBrace: this returns the closing paren's own index (callers slice + text[open+1:close] to get the contents), whereas _matchBrace returns one past the '}'.''' + depth = 0 + while i < len(text): + if text[i] == "(": + depth += 1 + elif text[i] == ")": + depth -= 1 + if depth == 0: + return i + i += 1 + return -1 + + +GLSL_KEYWORDS = {"if", "else", "for", "while", "switch", "do", "return", "case", "default"} + + +def _leadingComment(text, start): + '''Return the comment block (//... or /*...*/) directly above the definition at `start`. + + Walks whole lines upward from the definition's line, collecting comment lines; stops at a blank + line, a non-comment line, or the end of a previous definition. GLSL and WGSL comment syntax are + identical, so the captured text is re-emitted verbatim (naga discards comments, so we preserve + doc comments by re-attaching them here). Returns the block with a trailing newline, or '' when + there is no contiguous comment directly above.''' + lineStart = text.rfind("\n", 0, start) + 1 + lines = text[:lineStart].split("\n") + if lines and lines[-1] == "": + lines.pop() # drop the empty element following the final newline + collected = [] + inBlock = False # walking upward inside a /* ... */ whose opener is on an earlier line + for ln in reversed(lines): + s = ln.strip() + if inBlock: + collected.append(ln) + if s.startswith("/*"): + inBlock = False + continue + if s == "": + break + if s.startswith("//"): + collected.append(ln) + elif s.endswith("*/"): + collected.append(ln) + if not s.startswith("/*"): + inBlock = True + else: + break + if inBlock or not collected: + return "" # unbalanced block, or nothing found -- emit nothing rather than a partial + collected.reverse() + return "\n".join(collected) + "\n" + + +def fileLeadComment(text): + '''Return the file-level comment block at the very top of `text`, or ''. + + Only treated as a file-level comment when a blank line separates it from the code below; + otherwise it is the leading doc comment of the first function and is captured as that + function's 'lead' instead (avoids emitting it twice). Returned with a trailing blank line.''' + m = re.match(r"((?:[ \t]*(?://[^\n]*|/\*.*?\*/)[ \t]*\n)+)", text, re.S) + if not m: + return "" + if text[m.end():].startswith("\n"): + return m.group(1).rstrip("\n") + "\n\n" + return "" + + +def _stableAnchor(stmt): + '''Pick a token from a GLSL statement likely to survive naga's restructuring, or None. + + Preference order (most to least stable): an assignment's LHS name, a distinctive mx_* call, a + returned identifier. Pure literals are intentionally excluded -- they recur too often to place + a comment reliably. Used only to re-anchor best-effort inline comments; a miss just drops the + comment, never corrupts output.''' + m = re.match(r"(?:[A-Za-z_]\w*\s+)?([A-Za-z_]\w*)\s*(?:\[[^\]]*\])?\s*=(?!=)", stmt) + if m and m.group(1) not in GLSL_KEYWORDS: + return m.group(1) + m = re.search(r"\b(mx_\w+)\s*\(", stmt) + if m: + return m.group(1) + m = re.match(r"return\s+([A-Za-z_]\w*)", stmt) + if m and m.group(1) not in GLSL_KEYWORDS: + return m.group(1) + return None + + +def extractInlineComments(glslFull): + '''Return [(anchor, [comment_line, ...])] for whole-line // comments inside a GLSL body. + + Each contiguous run of `// ...` lines is anchored to a stable token of the next statement, so + it can be re-placed in naga's output. Best-effort: runs whose following statement has no stable + anchor are dropped. Only whole-line comments are considered (trailing comments are ignored).''' + groups = [] + pending = [] + started = False + for ln in glslFull.split("\n"): + s = ln.strip() + if not started: + if "{" in ln: + started = True + continue + if s.startswith("//"): + pending.append(s) + elif s == "" or s.startswith("/*") or s.endswith("*/"): + continue # blank / block comment -- keep pending attached to the next statement + elif pending: + anchor = _stableAnchor(s) + if anchor: + groups.append((anchor, list(pending))) + pending = [] + return groups + + +def injectInlineComments(glslFull, wgslText): + '''Re-attach GLSL inline comments to the transpiled WGSL body (best-effort, never corrupting). + + naga discards comments and restructures bodies, so each comment run is placed on its own line(s) + above the first WGSL line (at or after the previous placement) containing its anchor token. + Anchors are matched monotonically so ordering is preserved and deterministic; an unmatched + anchor's comments are dropped. Only whole-line insertion -- never mid-expression.''' + groups = extractInlineComments(glslFull) + if not groups: + return wgslText + lines = wgslText.split("\n") + searchFrom = 1 # skip the `fn ...(` signature line + inserts = [] + for anchor, comments in groups: + pat = re.compile(r"\b" + re.escape(anchor) + r"\b") + found = next((i for i in range(searchFrom, len(lines)) if pat.search(lines[i])), -1) + if found < 0: + continue # deterministic drop + inserts.append((found, comments)) + searchFrom = found + 1 + for idx, comments in reversed(inserts): + indent = lines[idx][:len(lines[idx]) - len(lines[idx].lstrip())] + for c in reversed(comments): + lines.insert(idx, indent + c) + if os.environ.get("MTLX_DEBUG"): + dropped = len(groups) - len(inserts) + if dropped: + print(f" inline comments: placed {len(inserts)}, dropped {dropped}") + return "\n".join(lines) + + +def parseFunctions(text): + '''Return [{ret, name, params, full, lead}] for every top-level function *definition*. + + Heuristic, not a real parser: we match the ` (` shape, then confirm it is a + definition (not a call or a prototype) by checking that a `{` follows the closing paren. The + keyword filter rejects control-flow that looks like ` (` (e.g. `else if (`), and the + `{`-lookahead rejects prototypes ending in `;`. `full` is the entire definition text (signature + through matching `}`) so callers can re-emit or wrap it; `lead` is the doc comment directly above + the definition (re-attached after transpilation since naga discards comments).''' + fns = [] + for m in re.finditer(r"\b([A-Za-z_]\w*)\s+([A-Za-z_]\w*)\s*\(", text): + if m.group(1) in GLSL_KEYWORDS or m.group(2) in GLSL_KEYWORDS: + continue # control-flow construct (e.g. `else if (...) {`), not a definition + openP = text.index("(", m.start(2)) + closeP = _matchParen(text, openP) + if closeP < 0: + continue + # Skip whitespace and comments after `)`; a definition has `{` here, a call/prototype has + # `;` or `,`. Comments (e.g. a trailing `// Ap` after the parameter list) must be stepped + # over too, or the definition is misread as a prototype and dropped. + j = closeP + 1 + while j < len(text): + if text[j].isspace(): + j += 1 + elif text.startswith("//", j): + nl = text.find("\n", j) + j = len(text) if nl < 0 else nl + 1 + elif text.startswith("/*", j): + end = text.find("*/", j) + j = len(text) if end < 0 else end + 2 + else: + break + if j >= len(text) or text[j] != "{": + continue # a call/prototype, not a definition + end = _matchBrace(text, j) + fns.append({"ret": m.group(1), "name": m.group(2), + "params": text[openP + 1:closeP].strip(), "full": text[m.start(1):end], + "lead": _leadingComment(text, m.start(1))}) + return fns + + +def fnParamTypes(paramsStr): + '''Extract bare GLSL parameter types from a parameter list string.''' + types = [] + for p in paramsStr.split(","): + p = re.sub(r"^\s*(const|in|out|inout)\s+", "", p.strip()) + if p: + types.append(re.sub(r"\s+", " ", + re.sub(r"\s+[A-Za-z_]\w*\s*(\[\d*\])?$", "", p)).strip()) + return tuple(types) + + +def fnParamNames(paramsStr): + '''Extract GLSL parameter identifiers from a parameter list string.''' + names = [] + for p in paramsStr.split(","): + p = re.sub(r"^\s*(const|in|out|inout)\s+", "", p.strip()) + if not p or "sampler2D" in p: + continue + tokens = p.split() + if tokens: + names.append(tokens[-1].split("[")[0]) + return names + + +def fnSigKey(fn): + '''(name, param-types) identity for a parsed function; distinguishes overloads sharing a name.''' + return (fn["name"], fnParamTypes(fn["params"])) + + +def expandLibTokens(text, libroot=None): + '''Replace MaterialX $-tokens and texture/sampler calls with naga-parseable GLSL. + + Node transpile uses per-function MTLXTOK_* sentinels; lib transpile uses libTokenFixups() + (NAGA_LIB_STUBS + $closureDataConstructor from HwConstants.cpp) plus the regex rewrites below + that stub sampler types and texture ops naga's GLSL frontend cannot parse.''' + if libroot is None: + libroot = _activeLibroot() + for rx, repl in libTokenFixups(libroot): + text = rx.sub(repl, text) + # Replace sampler types and texture ops with stubs naga accepts. + text = re.sub(r"\bsampler2D\s+mtlx_\w+\b", "int mtlx_sampler_stub", text) + text = re.sub(r"textureLod\s*\(\s*mtlx_tex_sampler\s*,\s*([^,]+),\s*([^)]+)\)", + r"mtlx_tex_lookup_rgb(\1, \2)", text) + text = re.sub(r"texture\s*\(\s*mtlx_tex_sampler\s*,\s*([^)]+)\)\.xy", + r"mtlx_tex_lookup_rg(\1)", text) + text = re.sub(r"texture\s*\(\s*mtlx_albedo_table\s*,\s*([^)]+)\)\.rg", + r"mtlx_tex_lookup_rg(\1)", text) + text = re.sub(r"texture\s*\(\s*mtlx_albedo_table\s*,\s*([^)]+)\)\.b", + r"mtlx_tex_lookup_b(\1)", text) + text = re.sub(r"textureSize\s*\(\s*mtlx_tex_sampler\s*,\s*0\s*\)", + r"vec2(256.0)", text) + text = re.sub(r"textureSize\s*\(\s*mtlx_albedo_table\s*,\s*0\s*\)\.x", + r"mtlx_tex_size_x()", text) + text = re.sub(r"textureSize\s*\(\s*mtlx_env_radiance_stub\s*,\s*0\s*\)\.x", + r"mtlx_tex_size_x()", text) + text = re.sub(r"texture\s*\(\s*mtlx_env_radiance_stub\s*,\s*([^)]+)\)\.rgb", + r"mtlx_tex_lookup_rgb(\1, 0.0)", text) + text = re.sub(r"texture\s*\(\s*mtlx_env_irradiance_stub\s*,\s*([^)]+)\)\.rgb", + r"mtlx_tex_lookup_rgb(\1, 0.0)", text) + text = re.sub(r"mx_latlong_map_lookup\s*\(([^,]+),\s*([^,]+),\s*([^,]+),\s*mtlx_env_radiance_stub\s*\)", + r"mtlx_tex_lookup_rgb(vec2(0.0), \3)", text) + text = re.sub(r"mx_latlong_map_lookup\s*\(([^,]+),\s*([^,]+),\s*([^,]+),\s*mtlx_env_irradiance_stub\s*\)", + r"mtlx_tex_lookup_rgb(vec2(0.0), \3)", text) + return text + + +def wgslFnName(glslName, types, overloaded): + '''Return the genwgsl function name for a GLSL overload. + + Delegates to mangle(): scheme/exception families are renamed (e.g. mx_square -> mx_square_f32), + everything else is unchanged. Used for lib *definitions*; remapCalls() applies the same map to + node *calls*. A None result (intentionally unsupported) falls back to the plain name here, since + a definition always needs a name; unsupported *calls* are handled in remapCalls.''' + return mangle(glslName, types, overloaded) or glslName + + +def denagaName(text, knownNames, collisions=True): + '''Reverse naga's identifier renaming for symbols the transpiler re-emits with clean names. + + naga's `Namer::call` (proc/namer.rs) rewrites an identifier deterministically: the first use of + a sanitized base gets a trailing `_` iff it ends in a digit or is a keyword/builtin, and any + later collision with an already-used name gets `_`. Our declarations (consts, struct fields, + prototypes) keep the clean GLSL spelling, so references naga emitted as `NAME_` or `NAME_` + must be mapped back to `NAME`. + + - collisions=True handles both `NAME_` (digit/keyword rule) and `NAME_` (collision rule); + use for consts/fields naga may see more than once. + - collisions=False handles only the single trailing `_`; use for names where a `_` form is a + *distinct* symbol that must be preserved (e.g. overloaded function stubs mx_foo_1, remapped + elsewhere). + + Longest name first so a shorter symbol can't capture a longer one's suffix; a suffixed token + that is itself a known name (e.g. FUJII_CONSTANT_1) is left untouched. Only listed names are + keyed, so naga's numbered *locals* are unaffected.''' + names = set(n for n in knownNames if n) + suffix = r"_\d*" if collisions else r"_" + for nm in sorted(names, key=len, reverse=True): + text = re.sub(r"\b" + re.escape(nm) + suffix + r"\b", + lambda m: m.group(0) if m.group(0) in names else nm, text) + return text + + +def applyFieldRenames(text, libroot=None): + '''Reverse naga's ends-in-digit `_` on struct fields declared in the closure preamble. + + FresnelData fields F0/F82/F90 end in a digit; naga suffixes references (F0_) but our preamble + re-emits the clean names. Digit-field names are discovered from wgslClosurePreamble() rather + than a hand-maintained list. The former tf_* -> thinfilm_* rename was removed: GLSL FresnelData + now uses thinfilm_* field names directly (mx_microfacet_specular.glsl).''' + if libroot is None: + libroot = _activeLibroot() + return denagaName(text, preambleDigitFields(libroot), collisions=False) + + +def resolveConstRefs(text, constNames): + '''Rewrite naga-suffixed references to module-scope consts back to their declared name. + + buildContext puts every lib const in naga's parse context, and the sibling-body path *also* + emits the file's preamble into the wrapped fragment, so naga sees `FRESNEL_MODEL_SCHLICK` twice + and renames body references to `FRESNEL_MODEL_SCHLICK_1`; a digit-ending const like + FUJII_CONSTANT_1 becomes FUJII_CONSTANT_1_. Delegates to denagaName() (both the digit/keyword + and collision rules apply to consts).''' + return denagaName(text, constNames, collisions=True) + + +def constNamesFromWgsl(consts): + '''Names of `const NAME: T = ...` declarations (WGSL form, from transpileGlslConsts).''' + return [m.group(1) for c in consts for m in [re.match(r"const (\w+):", c)] if m] + + +def glslConstNames(glslText): + '''Names of file-scope `const TYPE NAME = ...` declarations in GLSL context text (e.g. base).''' + return re.findall(r"\bconst\s+[\w<>]+\s+([A-Za-z_]\w*)\s*=", glslText) + + +def glslTypeToWgsl(typename): + return GLSL_TO_WGSL_TYPE.get(typename, typename) + + +# ----------------------------------------------------------------------------- +# derived preambles & token registry +# ----------------------------------------------------------------------------- +# +# Closure struct layouts and MaterialX $-token handling were previously duplicated as hand-written +# tables (CLOSURE_PREAMBLE, LIB_STRUCT_PREAMBLE, LIB_TOKEN_FIXUPS, LIB_FIELD_RENAMES). These helpers +# derive the same output from the canonical C++/GLSL sources so genglsl and the WGSL generator stay +# the single source of truth. + + +def setActiveLibroot(libroot): + '''Record the libraries root for preamble/token resolution (called from main()). + + libroot is typically `libraries/`; repo-relative paths (HwConstants.cpp, GlslSyntax.cpp, + genglsl/lib/*.glsl) are resolved via _repoRoot().''' + global _ACTIVE_LIBROOT + _ACTIVE_LIBROOT = Path(libroot).resolve() + + +def _activeLibroot(): + '''Return the libroot set by setActiveLibroot(); required before any preamble/token helper.''' + if _ACTIVE_LIBROOT is None: + raise RuntimeError("setActiveLibroot() must be called before transpilation") + return _ACTIVE_LIBROOT + + +def _repoRoot(libroot): + '''Parent of libroot (the MaterialX repo root when libroot is `libraries/`).''' + return Path(libroot).resolve().parent + + +def _readSourceText(path): + '''Read a repo source file as UTF-8 text.''' + return path.read_text(encoding="utf-8") + + +def loadHwTokenNames(libroot): + '''Return every MaterialX $-token name declared in HwConstants.cpp (T_* constants). + + Used by validateTokenCoverage to ensure lib GLSL only references tokens the C++ generator + knows about. Does not include stub values -- those are transpiler policy (NAGA_LIB_STUBS).''' + path = _repoRoot(libroot) / "source" / "MaterialXGenHw" / "HwConstants.cpp" + if not path.is_file(): + return set() + text = _readSourceText(path) + return set(re.findall(r'const string T_\w+\s*=\s*"(\$[^"]+)"', text)) + + +def nagaLibStubs(libroot): + '''Merge NAGA_LIB_STUBS with HwConstants values needed for naga-parseable lib transpile. + + Currently adds $closureDataConstructor (the makeClosureData function name string) so lib + sources using that token expand to a real GLSL identifier before naga runs.''' + stubs = dict(NAGA_LIB_STUBS) + hw = _repoRoot(libroot) / "source" / "MaterialXGenHw" / "HwConstants.cpp" + if hw.is_file(): + text = _readSourceText(hw) + m = re.search(r'const string CLOSURE_DATA_CONSTRUCTOR\s*=\s*"([^"]+)"', text) + if m: + stubs["$closureDataConstructor"] = m.group(1) + return stubs + + +def libTokenFixups(libroot): + '''(compiled_regex, replacement) pairs for expandLibTokens, longest token first. + + Longest-first ordering prevents a short $-token prefix from partially matching a longer one + (e.g. $texSampler vs $texSamplerSignature).''' + stubs = nagaLibStubs(libroot) + return [(re.compile(re.escape(tok)), repl) + for tok, repl in sorted(stubs.items(), key=lambda x: len(x[0]), reverse=True)] + + +def loadGlslSyntaxSnippets(repoRoot): + '''Extract struct and #define snippets from MaterialXGenGlsl/GlslSyntax.cpp. + + Parses the AggregateTypeSyntax definition strings (BSDF, VDF, surfaceshader, etc.) and the + EDF/material aliases exactly as the GLSL generator emits them. Node transpile uses the full set; + wgslClosurePreamble uses BSDF/VDF only (ClosureData/FresnelData come from genglsl/lib).''' + path = repoRoot / "source" / "MaterialXGenGlsl" / "GlslSyntax.cpp" + text = _readSourceText(path) + snippets = {} + for name in _GLSL_SYNTAX_STRUCT_NAMES: + m = re.search(rf'"struct {name} \{{[^"]+\}};"', text) + if m: + snippets[name] = m.group(0).strip('"') + for key, pattern in (("EDF", r'"#define EDF vec3"'), + ("material", r'"#define material surfaceshader"')): + m = re.search(pattern, text) + if m: + snippets[key] = m.group(0).strip('"') + return snippets + + +def _emitWgslStructsFromGlsl(text, only=None, skip=None): + '''Convert selected GLSL struct definitions in `text` to WGSL struct blocks. + + Shared by wgslClosurePreamble and transpileGlslStructs. Strips GLSL comments, maps scalar/ + vector types via GLSL_TO_WGSL_TYPE, and rewrites array fields (`name[N]` -> `array`). + When `only` is set, emit just those struct names (used to pull ClosureData/FresnelData from lib + files without re-emitting every struct in the source). When `skip` is set, omit those names + (used to drop closure structs provided centrally by wgslClosurePreamble).''' + out = [] + for m in re.finditer(r"\bstruct\s+(\w+)\s*\{([^}]*)\}\s*;?", text): + name = m.group(1) + if only is not None and name not in only: + continue + if skip is not None and name in skip: + continue + bodySrc = re.sub(r"/\*.*?\*/", "", m.group(2), flags=re.S) + bodySrc = re.sub(r"//[^\n]*", "", bodySrc) + fields = [] + for part in bodySrc.split(";"): + part = part.strip() + if not part: + continue + tokens = part.split() + if len(tokens) < 2: + continue + ftype, fname = tokens[0], tokens[1] + wtype = glslTypeToWgsl(ftype) + arr = re.match(r"(\w+)\[(\d+)\]$", fname) + if arr: + fname, wtype = arr.group(1), f"array<{wtype}, {arr.group(2)}>" + fields.append(f" {fname}: {wtype},") + if fields: + body = "\n".join(fields).rstrip(",") + out.append(f"struct {name} {{\n{body}\n}}") + return out + + +def _buildWgslClosurePreamble(libroot): + '''Build the WGSL struct preamble prepended to mx_closure_type.wgsl. + + Replaces the former LIB_STRUCT_PREAMBLE hand-written block. Sources: + - ClosureData from libraries/pbrlib/genglsl/lib/mx_closure_type.glsl + - BSDF, VDF from GlslSyntax.cpp (same layout as GLSL generator) + - EDF, material aliases (vec3f / surfaceshader) + - FresnelData from libraries/pbrlib/genglsl/lib/mx_microfacet_specular.glsl + surfaceshader is omitted here (WgslShaderGenerator::emitTypeDefinitions owns it).''' + libroot = Path(libroot).resolve() + repo = _repoRoot(libroot) + parts = [] + closureSrc = _readSourceText(libroot / "pbrlib" / "genglsl" / "lib" / "mx_closure_type.glsl") + specSrc = _readSourceText(libroot / "pbrlib" / "genglsl" / "lib" / "mx_microfacet_specular.glsl") + parts.extend(_emitWgslStructsFromGlsl(closureSrc, only={"ClosureData"})) + snippets = loadGlslSyntaxSnippets(repo) + for name in ("BSDF", "VDF"): + parts.extend(_emitWgslStructsFromGlsl(snippets[name])) + parts.append("alias EDF = vec3f;") + parts.append("alias material = surfaceshader;") + parts.extend(_emitWgslStructsFromGlsl(specSrc, only={"FresnelData"})) + return "\n\n".join(parts) + "\n" + + +def wgslClosurePreamble(libroot): + '''Cached WGSL closure preamble for mx_closure_type.wgsl (see _buildWgslClosurePreamble).''' + key = str(Path(libroot).resolve()) + if key not in _WGSL_CLOSURE_PREAMBLE: + _WGSL_CLOSURE_PREAMBLE[key] = _buildWgslClosurePreamble(libroot) + return _WGSL_CLOSURE_PREAMBLE[key] + + +def _buildGlslClosurePreamble(libroot): + '''Build the GLSL type preamble injected into node transpile context (buildContext). + + Replaces the former CLOSURE_PREAMBLE constant. Emits every aggregate/shader struct and the + EDF/material #defines from GlslSyntax.cpp so node fragments can reference BSDF, surfaceshader, + etc. without #include-ing the full lib tree.''' + repo = _repoRoot(libroot) + snippets = loadGlslSyntaxSnippets(repo) + lines = [snippets[n] for n in _GLSL_SYNTAX_STRUCT_NAMES] + lines.append(snippets["EDF"]) + lines.append(snippets["material"]) + return "\n".join(lines) + "\n" + + +def glslClosurePreamble(libroot): + '''Cached GLSL closure preamble for node transpile (see _buildGlslClosurePreamble).''' + key = str(Path(libroot).resolve()) + if key not in _GLSL_CLOSURE_PREAMBLE: + _GLSL_CLOSURE_PREAMBLE[key] = _buildGlslClosurePreamble(libroot) + return _GLSL_CLOSURE_PREAMBLE[key] + + +def preambleDigitFields(libroot): + '''Field names ending in a digit in the WGSL closure preamble (for denagaName). + + Scans wgslClosurePreamble() output so F0/F82/F90 (and any future digit-ending fields) are + reconciled automatically when the derived preamble changes.''' + return sorted(set(re.findall(r"^\s*(\w*\d)\s*:", wgslClosurePreamble(libroot), re.M))) + + +def transpileGlslStructs(text): + '''Convert top-level GLSL struct definitions to WGSL (emitted above lib fn bodies). + + Skips types provided centrally by wgslClosurePreamble (see LIB_PREAMBLE_STRUCTS). Delegates + to _emitWgslStructsFromGlsl, which strips GLSL comments before splitting fields (otherwise a + comment word is read as a `type name` pair) and rewrites array fields to WGSL.''' + return _emitWgslStructsFromGlsl(text, skip=LIB_PREAMBLE_STRUCTS) + + +def transpileGlslConsts(text): + '''Convert file-scope GLSL `const T name = ...` to WGSL `const name: T = ...` syntax. + + Only *top-level* consts are emitted. Function-local consts (e.g. the `const int SAMPLE_COUNT` + inside several directional-albedo helpers) are handled by naga inside the transpiled bodies; + hoisting them here would produce duplicate module-scope consts -- and since the same name + recurs across microfacet libs, redeclaration errors once several are included together. Blank + out function bodies first so their indented consts aren't matched by the file-scope regex.''' + scope = text + for fn in parseFunctions(text): + scope = scope.replace(fn["full"], "") + out = [] + for m in re.finditer(r"^\s*const\s+(\w+)\s+(\w+)\s*=\s*([^;]+);", scope, re.M): + out.append(f"const {m.group(2)}: {glslTypeToWgsl(m.group(1))} = {m.group(3)};") + return out + + +def libIncludes(text): + '''Return #include paths from a lib file (lib/... only).''' + return [inc for inc in re.findall(r'#include\s+"([^"]+)"', text) + if inc.startswith("lib/")] + + +def stripIncludes(text): + '''Remove whole `#include "..."` lines (naga sees inlined bodies, not include directives).''' + return re.sub(r'#include\s+"[^"]+"\s*\n', "", text) + + +def generatedBanner(srcRel): + '''The two-line "generated, do not edit" header prepended to every emitted .wgsl file.''' + return (f"// Generated from {srcRel} by source/MaterialXGenWgsl/tools/mxgenwgsl.py.\n" + f"// Do not edit -- re-run the transpiler to regenerate " + f"(see source/MaterialXGenWgsl/README.md).\n\n") + + +def writeGenerated(outPath, content, label): + '''Create parent dirs, write a generated .wgsl file, and log its OK line.''' + outPath.parent.mkdir(parents=True, exist_ok=True) + outPath.write_text(content, encoding="utf-8") + print(f" OK {label} -> {outPath}") + + +def topoSortLibFiles(libFiles): + '''Topological sort of lib/*.glsl by `#include \"lib/...\"` dependencies. + + Ensures mx_microfacet.glsl is processed before mx_microfacet_specular.glsl, etc., so + generated genwgsl/lib/*.wgsl includes resolve when nodes are transpiled later.''' + stems = {f.stem: f for f in libFiles} + deps = {} + for f in libFiles: + txt = f.read_text(encoding="utf-8") + deps[f.stem] = {Path(inc).stem for inc in libIncludes(txt) if Path(inc).stem in stems} + order, seen = [], set() + + def visit(stem): + if stem in seen: + return + seen.add(stem) + for d in sorted(deps.get(stem, ())): + visit(d) + order.append(stems[stem]) + + for stem in sorted(stems): + visit(stem) + return order + + +def postprocessWgslFn(text, fnName, protos, overloaded, libSymbols=None, remap=True, + glslParamNames=None): + '''Shared post-processing for one transpiled WGSL function (lib or node). + + Runs cleanup, type normalization, overload remapping, and optional lib arity checks. + Returns (text, []) on success or (None, unsupported_calls) when remap/arity fails.''' + text = cleanupFunction(text, glslParamNames) + for rx, repl in TYPE_FIXUPS: + text = rx.sub(repl, text) + text = re.sub(r"\b(\d+\.\d+(?:[eE][-+]?\d+)?)f\b", r"\1", text) + text = re.sub(r"(?", "vec3": "vec3", "vec4": "vec4", + "ivec2": "vec2", "ivec3": "vec3", "ivec4": "vec4", + "uvec2": "vec2", "uvec3": "vec3", "uvec4": "vec4", + "mat2": "mat2x2", "mat3": "mat3x3", "mat4": "mat4x4", +} + + +def _wgslSigTypes(fnText): + '''Parameter types from a (raw naga) WGSL fn signature, e.g. ('f32', 'vec3').''' + sig = re.match(r"fn\s+\w+\s*\(([^)]*)\)", fnText, re.S) + if not sig: + return () + out = [] + for p in sig.group(1).split(","): + m = re.match(r"[A-Za-z_]\w*\s*:\s*(.+)", p.strip(), re.S) + if m: + out.append(re.sub(r"\s+", "", m.group(1))) + return tuple(out) + + +def extractTranspiledFn(wgsl, glslName, wgslName, glslTypes=None): + '''Pull one non-stub function from naga output and rename to the target genwgsl name. + + Per-function lib/node transpile feeds naga a module with one real body; this extracts that + body and renames it (e.g. mx_square -> mx_square_f32) via wgslFnName. When several same-named + overloads appear in one naga module (sibling path, e.g. mx_ggx_dir_albedo's vec3, scalar, and + FresnelData forms), `glslTypes` disambiguates by matching the parameter signature; with a single + candidate the base-name match is used directly (signature match is skipped, since naga rewrites + `out` params to pointers and would otherwise not compare equal).''' + cands = [(name, t) for name, t in topLevelFns(wgsl) + if fnBase(name) == glslName and "{\n}" not in t and t.count("\n") > 1] + if not cands: + return None + chosen = cands[0] + if len(cands) > 1 and glslTypes is not None: + want = tuple(GLSL_TO_NAGA_TYPE.get(t, t) for t in glslTypes) + chosen = next(((n, t) for n, t in cands if _wgslSigTypes(t) == want), cands[0]) + name, t = chosen + return re.sub(r"(\bfn\s+)" + re.escape(name) + r"\b", r"\1" + wgslName, t) + + +# Closure/shader types for node transpile are built from GlslSyntax.cpp (glslClosurePreamble). +# WGSL lib types for mx_closure_type.wgsl use wgslClosurePreamble (genglsl/lib + GlslSyntax.cpp). + + +def nagaProtoParams(paramsStr): + '''Normalize GLSL prototype params for naga: strip the semantically-neutral `const`/`in` + qualifiers, KEEP `out`/`inout`, and drop sampler params naga's prototype parser rejects. + + Keeping `out`/`inout` is essential: naga renders those params as `ptr`, and when it + transpiles a *caller* it must see the callee's prototype as by-pointer so it passes the pointer + (e.g. `mx_normalmap_vector2(..., result)`) rather than dereferencing it (`(*result)`), which + would mismatch the transpiled definition's pointer parameter. naga accepts `out`/`inout` in a + forward declaration.''' + parts = [] + for p in paramsStr.split(","): + p = p.strip() + p = re.sub(r"^const\s+", "", p) # const: no call-site effect + p = re.sub(r"^in\s+", "", p) # `in` is the default (by value); `\s+` guards `int`/`inout` + if not p or "sampler2D" in p: + continue + parts.append(p) + return ", ".join(parts) + + +def buildContext(libroot, libs, nodes=True): + '''Build the naga parse context. Returns (base, protos, overloaded): + base - glslClosurePreamble + dedup'd #defines/consts/struct-defs from every genglsl lib. + protos - {(name, types): "prototype;"} for every function defined in any genglsl lib OR + node file, so helpers AND cross-node calls (e.g. mx_burn_color3 -> mx_burn_float) + and generator-supplied helpers (mx_environment_radiance) all resolve. + overloaded - the set of names appearing with more than one signature (informational; the + actual overload remapping is driven by mangle() in remapCalls). + Per node we emit `base` + every prototype except the one being defined. + + Pass nodes=False for lib transpilation: skip mx_*.glsl node files (their `inout` protos break + naga) and omit glslClosurePreamble (lib bodies are inlined whole; closure types come from + wgslClosurePreamble in mx_closure_type.wgsl only).''' + defines, consts, structs, protos = {}, {}, {}, {} + # Scan lib/ helpers and the node files themselves (the latter for cross-node prototypes). + sources = [] + for lib in libs: + gldir = libroot / lib / "genglsl" + if gldir.is_dir(): + sources += sorted((gldir / "lib").glob("*.glsl")) + if nodes: + # Node files too (for cross-node prototypes), minus the skipped ones (light shaders + # reference the dynamic LightData struct; texture nodes carry $-tokens). + sources += [f for f in sorted(gldir.glob("mx_*.glsl")) + if not any(p.search(f.stem) for p in SKIP_PATTERNS)] + # Collect #defines, consts, struct definitions, and function prototypes across all sources. + # `setdefault` keeps the first occurrence, so a symbol defined in several files is deduped. + for f in sources: + txt = f.read_text(encoding="utf-8") + for l in txt.splitlines(): + m = re.match(r"#define\s+(\w+)", l.lstrip()) + if m: + defines.setdefault(m.group(1), l.strip()) + for m in re.finditer(r"^\s*const\s+[\w<>]+\s+(\w+)\s*=[^;{]*;", txt, re.M): + consts.setdefault(m.group(1), m.group(0).strip()) + for m in re.finditer(r"\bstruct\s+(\w+)\s*\{[^}]*\}\s*;?", txt): + structs.setdefault(m.group(1), m.group(0)) + for fn in parseFunctions(txt): + types = fnParamTypes(fn["params"]) + protos.setdefault((fn["name"], types), + f"{fn['ret']} {fn['name']}({nagaProtoParams(fn['params'])});") + baseItems = list(defines.values()) + list(consts.values()) + list(structs.values()) + # Node context needs closure/shader types from GlslSyntax.cpp; lib-only pass skips them. + preamble = glslClosurePreamble(libroot) if nodes else "" + base = preamble + "\n".join(s for s in baseItems if "$" not in s) + protos = {k: v for k, v in protos.items() if "$" not in v} + # Names with more than one signature are GLSL-overloaded. WGSL has no overloading and the + # hand-written genwgsl lib renames/consolidates these (e.g. mx_square_f32, a single + # mx_ggx_dir_albedo), so the util cannot guarantee a transpiled call resolves against the lib. + protoNames = [nm for (nm, _t) in protos] + overloaded = {nm for nm in protoNames if protoNames.count(nm) > 1} + return base, protos, overloaded + + +# ----------------------------------------------------------------------------- +# cleanup (see mxwgslcleanup.py) +# ----------------------------------------------------------------------------- +# +# Param-copy shadow collapse lives in mxwgslcleanup (tree-sitter). inlineSingleUseTemps runs there +# after collapse; nothing else to define in this module. + + +def _countItems(s, brackets="()[]<>"): + '''Count top-level comma-separated items in `s` (0 if blank), ignoring commas nested inside + the given bracket pairs.''' + s = s.strip() + if not s: + return 0 + opens = brackets[0::2] + closes = brackets[1::2] + depth = 0 + n = 1 + for ch in s: + if ch in opens: + depth += 1 + elif ch in closes: + depth = max(0, depth - 1) + elif ch == "," and depth == 0: + n += 1 + return n + + +def buildWgslLibSymbols(libroot, libs, outroot=None): + '''Parse genwgsl `lib/` files into {function name: parameter count}. + + Reads from `outroot` when set (post-generation), else `libroot` (legacy hand-written tree). + ''' + symbols = {} + root = outroot if outroot is not None else libroot + for lib in libs: + wdir = root / lib / "genwgsl" / "lib" + if not wdir.is_dir(): + continue + for f in sorted(wdir.glob("*.wgsl")): + txt = f.read_text(encoding="utf-8") + for m in re.finditer(r"\bfn\s+([A-Za-z_]\w*)\s*\(", txt): + op = txt.index("(", m.start()) + cp = _matchParen(txt, op) + if cp >= 0: + symbols[m.group(1)] = _countItems(txt[op + 1:cp]) + return symbols + + +def checkLibArity(text, libSymbols): + '''Return a list of calls whose arg count disagrees with the genwgsl lib's parameter count.''' + bad = [] + for m in re.finditer(r"\b(mx_\w+)\s*\(", text): + name = m.group(1) + if name not in libSymbols: + continue # cross-node helper, builtin, or the node's own fn -- not a lib symbol + op = text.index("(", m.start()) + cp = _matchParen(text, op) + if cp < 0: + continue + nargs = _countItems(text[op + 1:cp], "()[]") # call args: no generics, keep '<'/'>' literal + if nargs != libSymbols[name]: + bad.append(f"{name} (call has {nargs}, lib expects {libSymbols[name]})") + return bad + + +# ----------------------------------------------------------------------------- +# scan-driven validation +# ----------------------------------------------------------------------------- +# +# These run off the same scan buildContext performs, so gaps between genglsl and the mangling +# tables (or the genwgsl lib) surface loudly instead of silently mis-emitting a name. + +def validateOverloadCoverage(protos, overloaded): + '''Return sorted overloaded (name, types) that mangle() cannot resolve. + + Every GLSL-overloaded helper must map to a genwgsl name (scheme or exception); an unresolved + overload would leak naga's `_N`-suffixed name into the output. A genuinely unsupported overload + should be an explicit `None` in EXCEPTIONS (which this treats as covered).''' + missing = [] + for key in sorted(protos): + name, types = key + if name in overloaded and key not in EXCEPTIONS and mangle(name, types, overloaded) is None: + missing.append(key) + return missing + + +def validateTokenCoverage(libroot, libs): + '''Return $-tokens in transpiled genglsl/lib sources missing from HwConstants or NAGA_LIB_STUBS. + + Two-tier check (replaces a single hand-maintained LIB_TOKEN_FIXUPS list): + 1. Token must be declared in HwConstants.cpp (loadHwTokenNames) -- same names the C++ + generator substitutes at shader link time. + 2. If the lib file is transpiled (not hand-written), token must also have a naga + stub (nagaLibStubs) so expandLibTokens can produce parseable GLSL before naga runs. + Hand-written lib files (skip_transpile.txt) are skipped (they are not fed to naga).''' + known = loadHwTokenNames(libroot) + stubs = set(nagaLibStubs(libroot)) + unhandled = {} + for lib in libs: + gllib = libroot / lib / "genglsl" / "lib" + if not gllib.is_dir(): + continue + for f in sorted(gllib.glob("*.glsl")): + if isHandWrittenLib(lib, f.stem): + continue + txt = f.read_text(encoding="utf-8") + for tok in re.findall(r"\$[A-Za-z_]\w*", txt): + if tok not in known: + unhandled.setdefault(tok, f"{f.name} (unknown to HwConstants)") + elif tok not in stubs: + # Declared in C++ but no naga-parseable stub for lib transpile. + unhandled.setdefault(tok, f"{f.name} (no NAGA stub)") + return unhandled + + +def validateLibNames(protos, libSymbols): + '''Return mangle() targets that are absent from the genwgsl lib symbol table. + + libSymbols should union the generated output and the hand-written stdlib names, so a scheme + that produces a name the real lib does not define (e.g. after a hand-written rename) fails + loudly. Overloads mangle() marks unsupported (None) are skipped -- their nodes stay hand-written.''' + missing = [] + for (name, types) in sorted(protos): + if name not in SUPPORTED_BASES: + continue + target = mangle(name, types, {name}) # name treated as overloaded for resolution + if target and target not in libSymbols: + missing.append(f"{name}{types} -> {target}") + return missing + + +def remapCalls(text, nodeFnName, protos, overloaded): + '''Rewrite overloaded/diverged helper calls to their genwgsl names via mangle(). + + naga numbers overload stubs (`mx_foo`, `mx_foo_1`, ...) by prototype-declaration order, and + protoBlock is emitted sorted by (name, types), so the i-th call name corresponds to the i-th + entry of the base's sorted type list. Returns (text, unsupported) where unsupported lists any + call mangle() resolves to None -- the caller fails the node so it stays hand-written. + ''' + byBase = {} + for (nm, types) in protos: + byBase.setdefault(nm, []).append(types) + for nm in byBase: + byBase[nm] = sorted(byBase[nm]) + + unsupported = [] + for base in sorted(SUPPORTED_BASES, key=len, reverse=True): + if base == nodeFnName or base not in byBase: + continue + for i, types in enumerate(byBase[base]): + callname = base if i == 0 else f"{base}_{i}" + if not re.search(r"\b" + re.escape(callname) + r"\s*\(", text): + continue + target = mangle(base, types, overloaded) + if not target: # None (adapted) or unmapped overload -> keep hand-written + unsupported.append(callname + "(" + ", ".join(types) + ")") + continue + text = re.sub(r"\b" + re.escape(callname) + r"\s*\(", target + "(", text) + return text, unsupported + + +# naga's canonical type spelling -> GLSL type used to key the mangle tables (inverse of +# GLSL_TO_NAGA_TYPE). +NAGA_TO_GLSL_TYPE = {v: k for k, v in GLSL_TO_NAGA_TYPE.items()} + + +def remapCallsByNagaSig(text, wgslModule, selfBase, overloaded): + '''Remap overloaded helper calls using the signatures naga actually assigned in wgslModule. + + The sibling-body path defines several overloads of a name in one module, and naga numbers the + stubs (`mx_foo`, `mx_foo_1`, ...) in an order that depends on its internal processing -- not the + sorted-proto order remapCalls assumes. Rather than guess the index, read each naga function's + real parameter signature straight from the module, recover its GLSL types, and resolve + (base, types) through mangle(). Longest names first so `mx_foo_1` isn't clobbered by `mx_foo`. + `selfBase` is the target's own name (skip, so a recursive call isn't misrouted). Returns + (text, unsupported) where unsupported lists calls mangle() resolves to None.''' + sigs = {} + for name, t in topLevelFns(wgslModule): + wt = _wgslSigTypes(t) + sigs[name] = tuple(NAGA_TO_GLSL_TYPE.get(x, x) for x in wt) + unsupported = [] + for name in sorted(sigs, key=len, reverse=True): + base = fnBase(name) + if base == selfBase or base not in SUPPORTED_BASES: + continue + if not re.search(r"\b" + re.escape(name) + r"\s*\(", text): + continue + if not _isMapped(base, sigs[name]): + continue # this exact overload isn't mapped (e.g. a same-base local helper) + target = mangle(base, sigs[name], overloaded) + if not target: # None -> adapted signature, keep hand-written + unsupported.append(name + "(" + ", ".join(sigs[name]) + ")") + continue + text = re.sub(r"\b" + re.escape(name) + r"\s*\(", target + "(", text) + return text, unsupported + + +def topLevelFns(wgsl): + '''Yield (name, text) for each top-level `fn` in a WGSL module.''' + for m in re.finditer(r"\bfn\s+([A-Za-z_]\w*)\s*\(", wgsl): + brace = wgsl.index("{", m.start()) + yield m.group(1), wgsl[m.start():_matchBrace(wgsl, brace)] + + +# ----------------------------------------------------------------------------- +# lib driver +# ----------------------------------------------------------------------------- +# +# transpileLibs() walks genglsl/lib/*.glsl in include order and writes genwgsl/lib/*.wgsl. +# Strategy mirrors nodes (wrap -> naga -> cleanup) but emits full helper libraries: +# - default: one naga invocation per function, with #included deps inlined as bodies +# - LIB_USE_SIBLING_BODIES: transitive in-file callee bodies (overload-aware topo sort) +# - mx_closure_type: static struct preamble + transpiled makeClosureData only + +def libNeedsSamplers(src): + '''True if this lib references samplers or $-token texture uniforms (needs TEXTURE_STUB_PREAMBLE).''' + return bool(re.search(r"\btexture\w*\s*\(|\$texSampler|\$albedoTable|\$envRadiance|\$envIrradiance", + src)) + + +def _extractMxCalls(fnBody, knownNames): + '''Return mx_* callees invoked in fnBody (body only, not the signature).''' + if "{" not in fnBody: + return set() + body = fnBody[fnBody.index("{") + 1:fnBody.rfind("}")] + return {m.group(1) for m in re.finditer(r"\b(mx_\w+)\s*\(", body) if m.group(1) in knownNames} + + +def sortFunctionsTopo(fns): + '''Reorder function definitions so callees precede callers (naga requirement). + + Overload-aware: each (name, param-types) pair is a distinct node so naga receives every + callee body, not just the first overload sharing a name.''' + if len(fns) <= 1: + return fns + + byName = {} + keyToFn = {} + for fn in fns: + k = fnSigKey(fn) + byName.setdefault(fn["name"], []).append(fn) + keyToFn[k] = fn + + localNames = set(byName.keys()) + deps = {} + for fn in fns: + k = fnSigKey(fn) + depKeys = [] + for callee in _extractMxCalls(fn["full"], localNames): + for calleeFn in byName.get(callee, []): + ck = fnSigKey(calleeFn) + if ck in keyToFn: + depKeys.append(ck) + deps[k] = depKeys + + orderKeys, temp, perm = [], set(), set() + + def visit(k): + if k in perm: + return + if k in temp: + return + temp.add(k) + for dk in deps.get(k, ()): + visit(dk) + temp.remove(k) + perm.add(k) + orderKeys.append(k) + + for fn in fns: + visit(fnSigKey(fn)) + + return [keyToFn[k] for k in orderKeys] + + +def rebuildFnBody(fns): + '''Concatenate function definitions in topo order for a single naga translation unit.''' + return "\n\n".join(fn["full"] for fn in fns) + + +def expandLibIncludes(text, gllibDir, seen=None): + '''Inline `#include \"lib/...\"` bodies so naga sees full dependency definitions. + + Recursive with `seen` to break include cycles. Only `lib/` includes are expanded (not + cross-library paths). Used for per-function dep context and whole-file transpile.''' + seen = seen or set() + + def replacer(match): + incPath = match.group(1) + if not incPath.startswith("lib/"): + return match.group(0) + stem = Path(incPath).stem + if stem in seen: + return "" + seen.add(stem) + incFile = gllibDir / (stem + ".glsl") + if not incFile.is_file(): + return "" + incText = expandLibTokens(incFile.read_text(encoding="utf-8")) + incText = expandLibIncludes(incText, gllibDir, seen) + incText = stripIncludes(incText) + return incText + "\n" + + return re.sub(r'#include\s+"([^"]+)"\s*\n', replacer, text) + + +def _mxCallsInText(text): + '''Return mx_* names invoked in text (function bodies and preamble, not signatures).''' + calls = set() + fns = parseFunctions(text) + if fns: + preamble = text[:text.index(fns[0]["full"])] + calls |= {m.group(1) for m in re.finditer(r"\b(mx_\w+)\s*\(", preamble)} + for fn in fns: + body = fn["full"][fn["full"].index("{") + 1:fn["full"].rfind("}")] + calls |= {m.group(1) for m in re.finditer(r"\b(mx_\w+)\s*\(", body)} + else: + calls |= {m.group(1) for m in re.finditer(r"\b(mx_\w+)\s*\(", text)} + return calls + + +def protoBlockForBody(bodySrc, protos, definedSigs): + '''Emit only prototypes for mx_* helpers referenced in bodySrc and not already defined.''' + called = _mxCallsInText(bodySrc) + lines = [] + for (nm, t), p in sorted(protos.items()): + if (nm, t) in definedSigs: + continue + if nm in called: + lines.append(p) + return "\n".join(lines) + + +def siblingCalleeClosure(targetFn, localFns): + '''Local functions transitively called by targetFn (all overloads of each called name).''' + byName = {} + for fn in localFns: + byName.setdefault(fn["name"], []).append(fn) + localNames = set(byName.keys()) + + needed = set() + stack = [targetFn] + while stack: + fn = stack.pop() + key = fnSigKey(fn) + if key in needed: + continue + needed.add(key) + for callee in _extractMxCalls(fn["full"], localNames): + for calleeFn in byName.get(callee, []): + if fnSigKey(calleeFn) not in needed: + stack.append(calleeFn) + + closure = [fn for fn in localFns if fnSigKey(fn) in needed] + return sortFunctionsTopo(closure) + + +def filePreambleBeforeFns(src): + '''Struct/const text before the first function definition in a lib fragment.''' + stripped = stripIncludes(src) + fns = parseFunctions(stripped) + if not fns: + return "" + return stripped[:stripped.index(fns[0]["full"])] + + +def applyLibWgslPatches(stem, text): + '''Stem-specific fixes for transpiled lib output (generator/runtime conventions).''' + if stem == "mx_microfacet_specular": + # Generator emits split env texture + sampler; match hand-port latlong signature. + text = re.sub( + r"fn mx_latlong_map_lookup\([^)]+\)[^{]*\{[^}]*\}", + """fn mx_latlong_map_lookup(dir: vec3f, transform: mat4x4f, lod: f32, envTex: texture_2d, envSampler: sampler) -> vec3f { + let envDir = normalize((transform * vec4f(dir, 0.0)).xyz); + let uv = mx_latlong_projection(envDir); + return textureSampleLevel(envTex, envSampler, uv, lod).rgb; +}""", + text, + count=1, + flags=re.S, + ) + return text + + +def transpileLibFileSiblings(glslPath, outPath, base, protos, overloaded, libName, src, raw, + header, samplerPreamble, gllibDir): + '''Per-function transpile with transitive in-file callee bodies (naga ordering fix).''' + stem = glslPath.stem + stripped = stripIncludes(src) + localFns = parseFunctions(stripped) + if not localFns: + return False + + filePreamble = filePreambleBeforeFns(src) + incLines = "\n".join( + f'#include "{i}"\n' for i in re.findall(r'#include\s+"([^"]+)"', raw) if i.startswith("lib/")) + depSrc = expandLibIncludes(incLines, gllibDir) if incLines else "" + depSrc = stripIncludes(depSrc) + for f in localFns: + depSrc = depSrc.replace(f["full"], "") + + depFns = parseFunctions(depSrc) if depSrc else [] + depPreamble = "" + if depFns: + depPreamble = depSrc[:depSrc.index(depFns[0]["full"])] + depNames = {fn["name"] for fn in depFns} + + outputs = [] + for targetFn in localFns: + types = fnParamTypes(targetFn["params"]) + outName = wgslFnName(targetFn["name"], types, overloaded) + siblings = siblingCalleeClosure(targetFn, localFns) + + # Pull in dep-file helpers transitively called from the sibling closure. + neededDep = [] + neededDepKeys = set() + stack = list(siblings) + while stack: + fn = stack.pop() + for callee in _extractMxCalls(fn["full"], depNames): + for depFn in depFns: + if depFn["name"] == callee: + k = fnSigKey(depFn) + if k not in neededDepKeys: + neededDepKeys.add(k) + neededDep.append(depFn) + stack.append(depFn) + + allBodyFns = sortFunctionsTopo(neededDep + siblings) + callees = [fn for fn in allBodyFns if fnSigKey(fn) != fnSigKey(targetFn)] + callees = sortFunctionsTopo(callees) + ordered = callees + [targetFn] + siblingBody = rebuildFnBody(ordered) + siblingSigs = {fnSigKey(fn) for fn in ordered} + definedSigs = siblingSigs + combined = filePreamble + depPreamble + siblingBody + protoBlock = protoBlockForBody(combined, protos, definedSigs) + glsl = ("#version 450\n" + LIB_PREAMBLE + samplerPreamble + base + "\n" + + filePreamble + depPreamble + "\n" + protoBlock + "\n" + siblingBody + + FRAG_MAIN_EPILOGUE) + wgsl, err = nagaTranspileGlsl( + glsl, outPath.parent / f"_debug_lib_{stem}_{targetFn['name']}.frag" + if os.environ.get("MTLX_DEBUG") else None) + if wgsl is None: + print(f" FAIL lib/{glslPath.name}::{targetFn['name']}: {err}") + return False + text = extractTranspiledFn(wgsl, targetFn["name"], outName, glslTypes=types) + if text is None: + print(f" FAIL lib/{glslPath.name}::{targetFn['name']}: not found in naga output") + return False + # Overloaded calls: resolve by naga's actual per-module signatures (its stub numbering here + # is topological, not the sorted order remapCalls assumes), then run the shared cleanup. + text, unsupported = remapCallsByNagaSig(text, wgsl, targetFn["name"], overloaded) + if unsupported: + print(f" FAIL lib/{glslPath.name}::{targetFn['name']}: unmapped call(s) {unsupported}") + return False + text, _ = postprocessWgslFn(text, targetFn["name"], protos, overloaded, remap=False, + glslParamNames=fnParamNames(targetFn["params"])) + outputs.append(targetFn.get("lead", "") + injectInlineComments(targetFn["full"], text)) + + structs = transpileGlslStructs(stripped) + consts = transpileGlslConsts(stripped) + preamble = ("\n\n".join(structs + consts) + "\n\n") if structs or consts else "" + srcRel = f"libraries/{libName}/genglsl/lib/{glslPath.name}" + banner = generatedBanner(srcRel) + allConsts = glslConstNames(base) + constNamesFromWgsl(consts) + body = resolveConstRefs("\n\n".join(outputs), allConsts) + body = applyLibWgslPatches(stem, body) + fileLead = fileLeadComment(raw) + writeGenerated(outPath, banner + fileLead + header + preamble + body + "\n", + f"lib/{glslPath.name}") + return True + + +def transpileLibFile(glslPath, outPath, base, protos, overloaded, libName, libroot): + '''Transpile one genglsl/lib/*.glsl file to genwgsl/lib/*.wgsl. + + Dispatches to _transpileClosureTypeLib, transpileLibFileSiblings, or per-function + transpile (default). Output: generated banner + #include lines + structs/consts + fn bodies.''' + raw = glslPath.read_text(encoding="utf-8") + src = expandLibTokens(raw) + stem = glslPath.stem + samplerPreamble = TEXTURE_STUB_PREAMBLE if libNeedsSamplers(raw) else "" + + if stem == "mx_closure_type": + return _transpileClosureTypeLib(glslPath, outPath, base, protos, overloaded, libName, + libroot) + + includes = [f'#include "{inc.replace(".glsl", ".wgsl")}"' + for inc in re.findall(r'#include\s+"([^"]+)"', raw)] + header = ("\n".join(includes) + "\n\n") if includes else "" + gllibDir = glslPath.parent + + if stem in LIB_USE_SIBLING_BODIES: + return transpileLibFileSiblings(glslPath, outPath, base, protos, overloaded, libName, + src, raw, header, samplerPreamble, gllibDir) + + stripped = stripIncludes(src) + localFns = parseFunctions(stripped) + if not localFns: + print(f" SKIP lib/{glslPath.name}: no functions found") + return False + + incLines = "\n".join( + f'#include "{i}"\n' for i in re.findall(r'#include\s+"([^"]+)"', raw) if i.startswith("lib/")) + depSrc = expandLibIncludes(incLines, gllibDir) if incLines else "" + depSrc = stripIncludes(depSrc) + # Remove local function bodies from depSrc — only the target fn body is transpiled per pass. + for f in localFns: + depSrc = depSrc.replace(f["full"], "") + + outputs = [] + for fn in localFns: + types = fnParamTypes(fn["params"]) + outName = wgslFnName(fn["name"], types, overloaded) + protoBlock = "\n".join( + p for (nm, t), p in sorted(protos.items()) + if not (nm == fn["name"] and t == types)) + glsl = ("#version 450\n" + LIB_PREAMBLE + samplerPreamble + base + "\n" + + depSrc + "\n" + protoBlock + "\n" + fn["full"] + + FRAG_MAIN_EPILOGUE) + wgsl, err = nagaTranspileGlsl( + glsl, outPath.parent / f"_debug_lib_{stem}_{fn['name']}.frag" + if os.environ.get("MTLX_DEBUG") else None) + if wgsl is None: + print(f" FAIL lib/{glslPath.name}::{fn['name']}: {err}") + return False + text = extractTranspiledFn(wgsl, fn["name"], outName) + if text is None: + print(f" FAIL lib/{glslPath.name}::{fn['name']}: not found in naga output") + return False + text, unsupported = postprocessWgslFn(text, fn["name"], protos, overloaded, remap=True, + glslParamNames=fnParamNames(fn["params"])) + if unsupported: + print(f" FAIL lib/{glslPath.name}::{fn['name']}: unmapped call(s) {unsupported}") + return False + outputs.append(fn.get("lead", "") + injectInlineComments(fn["full"], text)) + + structs = transpileGlslStructs(stripped) + consts = transpileGlslConsts(stripped) + preambleParts = structs + consts + preamble = ("\n\n".join(preambleParts) + "\n\n") if preambleParts else "" + + srcRel = f"libraries/{libName}/genglsl/lib/{glslPath.name}" + banner = generatedBanner(srcRel) + allConsts = glslConstNames(base) + constNamesFromWgsl(consts) + body = resolveConstRefs("\n\n".join(outputs), allConsts) + fileLead = fileLeadComment(raw) + writeGenerated(outPath, banner + fileLead + header + preamble + body + "\n", + f"lib/{glslPath.name}") + return True + + +def _transpileClosureTypeLib(glslPath, outPath, base, protos, overloaded, libName, libroot): + '''Special handler for mx_closure_type: prepend shared structs, transpile makeClosureData. + + GLSL only defines ClosureData + constructor macro; genwgsl nodes expect BSDF/VDF/FresnelData + types from this file. The struct block is wgslClosurePreamble(libroot) -- derived from + mx_closure_type.glsl, mx_microfacet_specular.glsl, and GlslSyntax.cpp -- not a hand-written + preamble. Function bodies still come from naga.''' + raw = glslPath.read_text(encoding="utf-8") + src = expandLibTokens(raw) + outputs = [] + for fn in parseFunctions(src): + types = fnParamTypes(fn["params"]) + outName = wgslFnName(fn["name"], types, overloaded) + protoBlock = "\n".join( + p for (nm, t), p in sorted(protos.items()) + if not (nm == fn["name"] and t == types)) + glsl = ("#version 450\n" + LIB_PREAMBLE + base + "\n" + protoBlock + + "\n" + fn["full"] + FRAG_MAIN_EPILOGUE) + wgsl, err = nagaTranspileGlsl(glsl) + if wgsl is None: + print(f" FAIL lib/{glslPath.name}::{fn['name']}: {err}") + return False + text = extractTranspiledFn(wgsl, fn["name"], outName) + if text is None: + print(f" FAIL lib/{glslPath.name}::{fn['name']}: not found in naga output") + return False + text, unsupported = postprocessWgslFn(text, fn["name"], protos, overloaded, remap=False, + glslParamNames=fnParamNames(fn["params"])) + if unsupported: + print(f" FAIL lib/{glslPath.name}::{fn['name']}: {unsupported}") + return False + outputs.append(fn.get("lead", "") + injectInlineComments(fn["full"], applyFieldRenames(text))) + + srcRel = f"libraries/{libName}/genglsl/lib/{glslPath.name}" + banner = generatedBanner(srcRel) + fileLead = fileLeadComment(raw) + writeGenerated(outPath, + banner + fileLead + wgslClosurePreamble(libroot).strip() + "\n\n" + + "\n\n".join(outputs) + "\n", + f"lib/{glslPath.name}") + return True + + +def transpileLibs(libroot, outroot, libs, base, protos, only=None): + '''Transpile all genglsl/lib/*.glsl helpers to genwgsl/lib/*.wgsl. + + Runs before node transpilation in main(). Returns a list of stems that failed. + Uses buildContext(nodes=False) for lib-only prototypes.''' + ok = failed = 0 + unexpected = [] + # Lib-only context: no node protos (inout) and no duplicate closure preamble. + libBase, libProtos, libOverloaded = buildContext(libroot, libs, nodes=False) + print("\nTranspiling genglsl/lib/ helpers...") + for lib in libs: + gllib = libroot / lib / "genglsl" / "lib" + if not gllib.is_dir(): + continue + libFiles = topoSortLibFiles(sorted(gllib.glob("*.glsl"))) + for glsl in libFiles: + if isHandWrittenLib(lib, glsl.stem): + print(f" KEEP lib/{glsl.name} (hand-written; in skip_transpile.txt)") + continue + if only and glsl.stem not in only: + continue + out = outroot / lib / "genwgsl" / "lib" / (glsl.stem + ".wgsl") + if transpileLibFile(glsl, out, libBase, libProtos, libOverloaded, lib, libroot): + ok += 1 + else: + failed += 1 + unexpected.append(glsl.stem) + print(f"Lib done: {ok} transpiled, {failed} failures.") + return unexpected + + +# ----------------------------------------------------------------------------- +# node driver +# ----------------------------------------------------------------------------- + +def transpile(nodePath, outPath, base, protos, overloaded, libSymbols): + nodeSrc = nodePath.read_text(encoding="utf-8") + nodeFns = parseFunctions(nodeSrc) + if not nodeFns: + print(f" SKIP {nodePath.name}: no functions found") + return False + + outputs = [] + for fn in nodeFns: + # Supply every known prototype except the function being defined here (siblings and + # cross-node helpers are all in `protos`, keyed by name+types). + # Emit sorted by (name, types) so each overloaded base's prototypes are declared in the + # same order remapCalls indexes them (naga numbers overload stubs by declaration order). + protoBlock = "\n".join(p for (nm, _t), p in sorted(protos.items()) if nm != fn["name"]) + body = fn["full"] + + # MaterialX `$`-tokens (e.g. $blur, $albedoTable) aren't valid GLSL identifiers, so naga + # can't parse them. Swap each `$tok` for a legal placeholder `MTLXTOK_tok` before transpiling + # and restore it afterwards; tokenMap records the reverse mapping for this function. + tokenMap = {} + + def sentinel(m): + s = "MTLXTOK_" + m.group(1) + tokenMap[s] = m.group(0) + return s + body = re.sub(r"\$([A-Za-z_]\w*)", sentinel, body) + + # Assemble a complete, naga-parseable fragment shader: version + shared context (structs, + # #defines, prototypes) + this one real function body + a do-nothing entry point. naga's GLSL + # frontend requires a staged shader with a main(); it keeps non-entry functions in the output, + # which is exactly the one body we want back. + glsl = "#version 450\n" + base + "\n" + protoBlock + "\n" + body + FRAG_MAIN_EPILOGUE + + wgsl, err = nagaTranspileGlsl( + glsl, outPath.parent / f"_debug_{fn['name']}.frag" if os.environ.get("MTLX_DEBUG") else None) + if wgsl is None: + print(f" FAIL {nodePath.name}::{fn['name']}: {err}") + return False + + # Pull our one real function out of naga's module (context prototypes become empty stubs) and + # rename it back to the clean source name -- the same helper the lib path uses. + text = extractTranspiledFn(wgsl, fn["name"], fn["name"]) + if text is None: + print(f" FAIL {nodePath.name}::{fn['name']}: not found in naga output") + return False + + # Shared post-processing: cleanup, type/float-literal normalization, denaga reconciliation, + # overload remap, and lib-arity check. Anything the tables mark unsupported (adapted + # signatures, e.g. the FresnelData BSDF helpers) fails the node so it falls back to the + # hand-written .wgsl -- yielding a reduced (generated + hand-written) lib. + text, unsupported = postprocessWgslFn(text, fn["name"], protos, overloaded, + libSymbols=libSymbols, remap=True, + glslParamNames=fnParamNames(fn["params"])) + if unsupported: + print(f" FAIL {nodePath.name}::{fn['name']}: calls adapted/unmapped helper(s) " + f"{unsupported} (kept hand-written)") + return False + + # Restore MaterialX $-tokens last; sentinels are untouched by remap/arity (which match mx_*). + for s, tok in tokenMap.items(): + text = text.replace(s, tok) + + outputs.append(fn.get("lead", "") + injectInlineComments(fn["full"], text)) + + # Re-emit the node's own #include lines with the .glsl->.wgsl extension swap, so the generated + # fragment pulls in the same lib helpers (now their WGSL versions) the GLSL source did. A node + # file may define several functions (e.g. a vector2 + float variant); join them in source order. + includes = [f'#include "{inc.replace(".glsl", ".wgsl")}"' + for inc in re.findall(r'#include\s+"([^"]+)"', nodeSrc)] + header = ("\n".join(includes) + "\n\n") if includes else "" + + # Banner marking the file as generated, so it is visually distinct from the hand-written + # genwgsl nodes it sits next to. Names the genglsl source (lib-relative, so the line is stable + # regardless of where --libraries points) and the tool. No timestamp: re-running must produce + # byte-identical output so `git diff` cleanly surfaces drift. + srcRel = f"libraries/{nodePath.parent.parent.name}/genglsl/{nodePath.name}" + banner = generatedBanner(srcRel) + + fileLead = fileLeadComment(nodeSrc) + writeGenerated(outPath, banner + fileLead + header + "\n\n".join(outputs) + "\n", + nodePath.name) + return True + + +def cleanGenerated(libroot): + '''Delete every generated genwgsl *.wgsl under --libraries, keeping only the hand-written files + listed in skip_transpile.txt. Restores a hand-written-only library after an in-place + (`--out libraries`) generation run. Needs no naga.''' + libroot = Path(libroot) + keep = loadDoNotTranspile() + removed = kept = 0 + print(f"Cleaning generated genwgsl .wgsl under {libroot} ...") + for wgsl in sorted(libroot.glob("*/genwgsl/**/*.wgsl")): + if wgsl.relative_to(libroot).as_posix() in keep: + kept += 1 + continue + print(f" RM {wgsl.relative_to(libroot).as_posix()}") + wgsl.unlink() + removed += 1 + print(f"Clean done: {removed} generated file(s) removed, {kept} hand-written kept.") + return removed + + +def main(): + ap = argparse.ArgumentParser(description="Transpile genglsl node fragments to genwgsl.") + ap.add_argument("--libraries", default="libraries") + ap.add_argument("--out", default="build/genwgsl_generated") + ap.add_argument("--only", nargs="*") + ap.add_argument("--clean", action="store_true", + help="Delete generated genwgsl .wgsl under --libraries (keeping the hand-written " + "files in skip_transpile.txt), then exit. Does not run naga.") + ap.add_argument("--naga", help="Path to the naga CLI (overrides the NAGA env var / PATH lookup).") + args = ap.parse_args() + + if args.clean: + cleanGenerated(args.libraries) + return 0 + + # Resolve and preflight naga so a missing tool fails once, with guidance, instead of per node. + global NAGA + NAGA = resolveNaga(args.naga) + version = nagaVersion(NAGA) + if not version: + print(f"ERROR: naga CLI not found or not runnable (tried '{NAGA}').\n" + " Install it with `cargo install naga-cli`, or pass --naga / set the\n" + " NAGA environment variable. See https://github.com/gfx-rs/wgpu/tree/trunk/naga.", + file=sys.stderr) + return 2 + print(f"Using naga: {NAGA} ({version})") + + # tree-sitter (readability cleanup) is optional; announce once if absent so the verbose output + # isn't mistaken for a bug. Generation itself does not need it. + if not cleanupAvailable(): + print("INFO: tree-sitter-language-pack not installed; skipping WGSL readability cleanup " + "(output is valid WGSL but verbose). Install: pip install -r " + "source/MaterialXGenWgsl/tools/requirements-transpile.txt") + + libroot, outroot = Path(args.libraries), Path(args.out) + # Preamble/token helpers resolve repo paths relative to libroot; must run before transpilation. + setActiveLibroot(libroot) + libs = discoverLibs(libroot) + base, protos, overloaded = buildContext(libroot, libs) + + # Scan-driven preflight: unresolved overloads or unhandled $-tokens are hard errors (they would + # mis-emit a name or fail naga later, with a more obscure message). + validationFailed = False + uncovered = validateOverloadCoverage(protos, overloaded) + if uncovered: + validationFailed = True + print("ERROR: overloaded helpers with no mangle() mapping (add a SUFFIX_SCHEME/EXCEPTIONS " + "entry): " + ", ".join(f"{n}{t}" for n, t in uncovered)) + unhandledTokens = validateTokenCoverage(libroot, libs) + if unhandledTokens: + validationFailed = True + print("ERROR: $-tokens in transpiled lib sources not covered by HwConstants/NAGA_LIB_STUBS: " + + ", ".join(f"{tok} ({src})" for tok, src in sorted(unhandledTokens.items()))) + + # Lib helpers first: nodes #include genwgsl/lib/*.wgsl and checkLibArity reads fresh output. + libUnexpected = transpileLibs(libroot, outroot, libs, base, protos, only=args.only) + libSymbols = buildWgslLibSymbols(libroot, libs, outroot=outroot) + + # Validate mangle() names against the actual genwgsl lib (hand-written stdlib from libroot, + # generated helpers from outroot). Skipped for a partial --only run, which won't regenerate + # every referenced helper. + if not args.only: + valSymbols = dict(buildWgslLibSymbols(libroot, libs)) + valSymbols.update(libSymbols) + missingNames = validateLibNames(protos, valSymbols) + if missingNames: + validationFailed = True + print("ERROR: mangle() produced names absent from the genwgsl lib: " + + ", ".join(missingNames)) + + ok = skip = 0 + nodeExpected, nodeUnexpected = [], [] + print("\nTranspiling genglsl node fragments...") + for lib in libs: + gldir = libroot / lib / "genglsl" + if not gldir.is_dir(): + continue + for glsl in sorted(gldir.glob("mx_*.glsl")): + baseName = glsl.stem + if args.only and baseName not in args.only: + continue + if any(p.search(baseName) for p in SKIP_PATTERNS): + print(f" SKIP {glsl.name}: texture/sampler node (unsupported by naga)") + skip += 1 + continue + out = outroot / lib / "genwgsl" / (baseName + ".wgsl") + if transpile(glsl, out, base, protos, overloaded, libSymbols): + ok += 1 + if baseName in EXPECTED_FALLBACK: + # A node we expected to stay hand-written now transpiles cleanly -- the lib + # divergence it depended on was probably resolved. Not fatal, but worth flagging. + print(f" WARN {glsl.name}: now transpiles cleanly; remove it from " + f"EXPECTED_FALLBACK and commit the generated version.") + elif baseName in EXPECTED_FALLBACK: + nodeExpected.append(baseName) + else: + nodeUnexpected.append(baseName) + + print(f"\nDone: {ok} nodes transpiled, {skip} skipped, " + f"{len(nodeExpected)} expected fallbacks, {len(nodeUnexpected)} unexpected failures.") + allUnexpected = libUnexpected + nodeUnexpected + if allUnexpected: + print("ERROR: files that should transpile failed: " + ", ".join(sorted(set(allUnexpected)))) + return 1 + if validationFailed: + print("ERROR: scan-driven validation failed (see messages above).") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/source/MaterialXGenWgsl/tools/mxwgslcleanup.py b/source/MaterialXGenWgsl/tools/mxwgslcleanup.py new file mode 100644 index 0000000000..9bb2ad1cb0 --- /dev/null +++ b/source/MaterialXGenWgsl/tools/mxwgslcleanup.py @@ -0,0 +1,788 @@ +'''Post-process naga-emitted WGSL function bodies for readability. + +Uses tree-sitter (the `tree-sitter-language-pack` WGSL grammar) for statement/identifier +analysis, then applies conservative source edits: collapse param-copy shadows, restore GLSL +parameter names, promote readonly/mutable locals, unwrap naga's redundant compound blocks, +flatten else-if chains, and simplify ptr/assignment parens. Parsing only — emission is +byte-range surgery on naga output. +''' + +from __future__ import annotations + +import re +import sys + +_PARSER = None + + +def fnBase(name): + '''Strip naga's trailing `_` / `_N` disambiguation suffix.''' + return re.sub(r"_\d*$", "", name) + + +def _parser(): + global _PARSER + if _PARSER is None: + from tree_sitter_language_pack import get_parser + _PARSER = get_parser("wgsl") + return _PARSER + + +_CLEANUP_AVAILABLE = None + + +def cleanupAvailable(): + '''Whether the tree-sitter WGSL grammar can be loaded (probed once, cached). When False the + readability cleanup is skipped and callers keep the raw -- valid but verbose -- naga output. + Lets generation run on interpreters with no tree-sitter-language-pack wheel (e.g. Python 3.9).''' + global _CLEANUP_AVAILABLE + if _CLEANUP_AVAILABLE is None: + try: + _parser() + _CLEANUP_AVAILABLE = True + except Exception: + _CLEANUP_AVAILABLE = False + return _CLEANUP_AVAILABLE + + +def _nodeText(node, src): + return src[node.start_byte:node.end_byte].decode("utf-8") + + +def _firstIdent(node, src): + if node.type == "identifier": + return _nodeText(node, src) + for child in node.children: + found = _firstIdent(child, src) + if found: + return found + return None + + +def _functionDecl(root): + if root.type == "function_declaration": + return root + for child in root.children: + if child.type == "function_declaration": + return child + return None + + +def _compoundBody(fnDecl): + for child in fnDecl.children: + if child.type == "compound_statement": + return child + return None + + +def _paramNames(fnDecl, src): + names = [] + for child in fnDecl.children: + if child.type != "parameter_list": + continue + for param in child.children: + if param.type == "parameter": + ident = _firstIdent(param, src) + if ident: + names.append(ident) + return names + + +def _stmtList(compound): + '''Direct statement children of a compound_statement (the language-pack grammar + has no `statement` wrapper; real statement nodes sit directly under the block).''' + return [c for c in compound.children if c.is_named and c.type != "comment"] + + +def _statements(body): + yield from _stmtList(body) + + +def _varDeclName(stmt, src): + '''Declared name for a `var`/`let` variable_statement, else None.''' + if stmt.type != "variable_statement": + return None + return _firstIdent(stmt, src) + + +def _copyAssignment(stmt, src): + '''If stmt is `shadow = param` with simple idents, return (shadow, param).''' + if stmt.type != "assignment_statement": + return None + kids = list(stmt.children) + if len(kids) != 3: + return None + lhsNode, opNode, rhsNode = kids + if lhsNode.type != "lhs_expression" or opNode.type != "=": + return None + if rhsNode.type != "identifier": + return None + lhs = _firstIdent(lhsNode, src) + if not lhs or _nodeText(lhsNode, src).strip() != lhs: + return None # member/index write (`x.f = ...`), not a whole-variable copy + rhs = _nodeText(rhsNode, src) + if lhs and rhs: + return lhs, rhs + return None + + +def _lineRange(stmt, src): + '''Full source line(s) for a statement, including indent and trailing newline. + + The language-pack grammar leaves the trailing `;` as a sibling token outside the + statement node, so extend to the end of the physical line (next newline).''' + lineStart = src.rfind(b"\n", 0, stmt.start_byte) + 1 + nl = src.find(b"\n", stmt.end_byte) + lineEnd = len(src) if nl == -1 else nl + 1 + return lineStart, lineEnd + + +def _stmtSpan(stmt, src): + '''Node byte span extended past an immediately following `;` sibling token.''' + end = stmt.end_byte + i = end + while i < len(src) and src[i:i + 1] in (b" ", b"\t"): + i += 1 + if i < len(src) and src[i:i + 1] == b";": + end = i + 1 + return stmt.start_byte, end + + +def _assignmentTargets(body, src): + '''Map assigned identifier -> list of assignment_statement nodes.''' + targets = {} + stack = [body] + while stack: + node = stack.pop() + if node.type == "assignment_statement": + lhs = None + for part in node.children: + if part.type == "lhs_expression": + lhs = _firstIdent(part, src) + break + if lhs: + targets.setdefault(lhs, []).append(node) + stack.extend(node.children) + return targets + + +def _isWholeVarAssign(assignNode, src, name): + '''True if assignNode rebinds the whole variable `name` (`name = ...`), not a member/index + write (`name.field = ...`, `name[i] = ...`). Promotion may only fold whole-variable assigns.''' + for child in assignNode.children: + if child.type == "lhs_expression": + return _nodeText(child, src).strip() == name + return False + + +def _identUsesOutside(body, src, name, skipRanges): + '''Count ident uses not wholly inside any (start, end) skip range.''' + count = 0 + stack = [body] + while stack: + node = stack.pop() + if node.type == "identifier" and _nodeText(node, src) == name: + pos = node.start_byte + if not any(s <= pos < e for s, e in skipRanges): + count += 1 + stack.extend(node.children) + return count + + +def _applyDeletes(src, deleteRanges): + '''Delete byte ranges from UTF-8 source (end-exclusive).''' + data = bytearray(src.encode("utf-8")) + for start, end in sorted(deleteRanges, key=lambda r: r[0], reverse=True): + del data[start:end] + return data.decode("utf-8") + + +def _applyRenames(text, renames): + '''Word-boundary identifier renames, longest key first.''' + for old, new in sorted(renames.items(), key=lambda kv: len(kv[0]), reverse=True): + if old != new: + text = re.sub(r"\b" + re.escape(old) + r"\b", new, text) + return text + + +def _fixMutateShadowReads(text, shadow, param): + '''After removing a param copy-in, pre-init reads and the first assign RHS use the param.''' + assignPat = re.compile( + r"^[ \t]*" + re.escape(shadow) + r"\s*=\s*(.+?);\s*$", + re.MULTILINE) + m = assignPat.search(text) + if not m: + return text + + before = text[:m.start()] + assignLine = m.group(0) + expr = m.group(1) + after = text[m.end():] + + varDecl = re.compile(r"^[ \t]*var\s+" + re.escape(shadow) + r"\b") + fixedBefore = [] + for line in before.splitlines(keepends=True): + if varDecl.match(line): + fixedBefore.append(line) + else: + fixedBefore.append( + re.sub(r"\b" + re.escape(shadow) + r"\b", param, line)) + before = "".join(fixedBefore) + + exprFixed = re.sub(r"\b" + re.escape(shadow) + r"\b", param, expr) + stripped = exprFixed.strip() + if re.fullmatch(r"-\s*\(\s*" + re.escape(param) + r"\s*\)", stripped): + exprOut = f"(-{param})" + else: + exprOut = exprFixed + + indent = re.match(r"^([ \t]*)", assignLine).group(1) + newline = "\n" if assignLine.endswith("\n") else "" + return before + f"{indent}{shadow} = {exprOut};{newline}" + after + + +def _collapseParamShadows(fnText, glslParamNames=None): + src = fnText.encode("utf-8") + tree = _parser().parse(src) + root = tree.root_node + # The language-pack grammar cannot parse bare multi-arg function-call statements + # (`foo(a, b, c);`) and marks the tree as having errors, but those errors are local + # to the call statements; the declarations/copy-ins we act on parse correctly. So do + # not bail on has_error -- operate on the well-formed statements best-effort. + fnDecl = _functionDecl(root) + if fnDecl is None: + return fnText + + body = _compoundBody(fnDecl) + if body is None: + return fnText + + wgslParams = _paramNames(fnDecl, src) + assignTargets = _assignmentTargets(body, src) + stmts = list(_statements(body)) + topLevel = {(s.start_byte, s.end_byte) for s in stmts} + + varDeclStmts = {} + copyStmts = {} + shadowToParam = {} + for stmt in stmts: + shadow = _varDeclName(stmt, src) + if shadow: + varDeclStmts[shadow] = stmt + pair = _copyAssignment(stmt, src) + if pair: + lhs, rhs = pair + if rhs in wgslParams and fnBase(lhs) == fnBase(rhs): + shadowToParam[lhs] = rhs + copyStmts[lhs] = stmt + + # Pair naga param names with GLSL source names (same position). + paramRename = {} + if glslParamNames and len(glslParamNames) == len(wgslParams): + for wgslName, glslName in zip(wgslParams, glslParamNames): + if wgslName != glslName: + paramRename[wgslName] = glslName + + deleteRanges = [] + identRenames = {} + mutateFold = [] # (shadow, param) pairs needing self-negate fold after copy removal + + for shadow, param in shadowToParam.items(): + varStmt = varDeclStmts.get(shadow) + copyStmt = copyStmts.get(shadow) + if varStmt is None or copyStmt is None: + continue + + assigns = assignTargets.get(shadow, []) + skip = [_stmtSpan(varStmt, src), _stmtSpan(copyStmt, src)] + usesOutside = _identUsesOutside(body, src, shadow, skip) + + if len(assigns) == 1 and usesOutside == 0: + deleteRanges.extend(skip) + continue + + if len(assigns) == 1: + deleteRanges.extend(skip) + identRenames[shadow] = param + continue + + # The shadow is reassigned (beyond the copy-in). Dropping the `shadow = param` copy and + # folding is only safe when every reassignment is at the SAME (top-level) scope as the copy: + # otherwise a sibling branch or loop iteration would read the shadow uninitialized (e.g. a + # tangent reassigned only in the reflection branch but read in the environment branch). If + # any reassignment is nested, keep naga's copy-in intact. + keepCopy = False + for a in assigns: + if a.start_byte >= copyStmt.start_byte and a.end_byte <= copyStmt.end_byte: + continue # this is the copy-in itself + # A member/index write (`shadow.field = …`, `shadow[i] = …`) mutates the copy in + # place and has no whole-variable reassignment for `_fixMutateShadowReads` to fold, + # so the `shadow = param` copy-in must stay to seed the other fields — otherwise the + # shadow is read uninitialized (e.g. `fd.refraction = true` on a FresnelData copy). + if not _isWholeVarAssign(a, src, shadow): + keepCopy = True + break + st = _stmtContaining(body, a) + if st is None or (st.start_byte, st.end_byte) not in topLevel: + keepCopy = True + break + if keepCopy: + continue + + deleteRanges.append(_stmtSpan(copyStmt, src)) + mutateFold.append((shadow, param)) + + for wgslName, glslName in paramRename.items(): + identRenames[wgslName] = glslName + for shadow, target in list(identRenames.items()): + if target == wgslName: + identRenames[shadow] = glslName + + text = _applyDeletes(fnText, deleteRanges) + text = _applyRenames(text, identRenames) + for shadow, param in mutateFold: + text = _fixMutateShadowReads( + text, shadow, identRenames.get(param, param)) + while True: + cleaned = re.sub(r"\n[ \t]*\n[ \t]*\n", "\n\n", text) + if cleaned == text: + break + text = cleaned + text = re.sub(r"(\{\n)\s*\n+", r"\1", text) + return text + + +def _assignmentRhsText(assignNode, src): + '''RHS expression text of `lhs = ` (the rhs is the node's last child; + it is not wrapped in an `expression` node in the language-pack grammar).''' + kids = list(assignNode.children) + if len(kids) >= 3 and kids[0].type == "lhs_expression": + return _nodeText(kids[-1], src) + return None + + +def _stmtContaining(body, innerNode): + '''Smallest statement node (a direct child of some compound_statement) that + contains innerNode. With no `statement` wrapper, a statement is any named node + whose parent is a compound_statement.''' + node = innerNode + while node is not None and node != body: + parent = node.parent + if parent is not None and parent.type == "compound_statement": + return node + node = parent + return None + + +def _lineIndent(stmt, src): + lineStart = src.rfind(b"\n", 0, stmt.start_byte) + 1 + prefix = src[lineStart:stmt.start_byte] + m = re.match(rb"[ \t]*", prefix) + return prefix[: m.end()].decode("utf-8") if m else "" + + +def _pickLocalName(name, wgslParams, taken): + '''Map naga locals to readable names without shadowing params.''' + base = fnBase(name) + if any(fnBase(p) == base for p in wgslParams): + candidate = f"{base}_f" + else: + candidate = base + if candidate in taken: + return name + return candidate + + +def _applyEdits(text, deleteRanges, replacements): + '''Apply byte-range deletes and (start, end, newText) replacements.''' + edits = [(start, end, "") for start, end in deleteRanges] + edits.extend(replacements) + edits.sort(key=lambda item: item[0], reverse=True) + data = bytearray(text.encode("utf-8")) + for start, end, new in edits: + replacement = new.encode("utf-8") if isinstance(new, str) else new + data[start:end] = replacement + return data.decode("utf-8") + + +def _braceTokens(compound): + openBrace = closeBrace = None + for child in compound.children: + if child.type == "{": + openBrace = child + elif child.type == "}": + closeBrace = child + return openBrace, closeBrace + + +def _compoundInnerWrapper(outer): + '''If outer's sole statement is a nested compound, return that inner compound.''' + stmts = _stmtList(outer) + if len(stmts) != 1: + return None + return stmts[0] if stmts[0].type == "compound_statement" else None + + +def _cleanupMutableLocals(fnText, glslParamNames=None): + """Promote hoisted `var` + first assign to `var clean = …` when later reassigned.""" + src = fnText.encode("utf-8") + tree = _parser().parse(src) + root = tree.root_node + fnDecl = _functionDecl(root) + if fnDecl is None: + return fnText + body = _compoundBody(fnDecl) + if body is None: + return fnText + + wgslParams = _paramNames(fnDecl, src) + reserved = set(wgslParams) + if glslParamNames: + reserved.update(glslParamNames) + + varDecls = {} + for stmt in _statements(body): + name = _varDeclName(stmt, src) + # Only naga's UNINITIALIZED hoist (`var name: T;`) is a promotion candidate. An initialized + # declaration (`var i = 0i;`, `var I = vec3(0.0);`) is a real accumulator/counter whose + # value must survive; merging a later (often self-referencing) assignment into it is wrong. + if name and b"=" not in src[stmt.start_byte:stmt.end_byte]: + varDecls[name] = stmt + + assignTargets = _assignmentTargets(body, src) + # Byte ranges of the function body's direct (top-level) statements. naga hoists a `var name: T;` + # to this scope when the value is assigned in multiple branches; the promotion below may only + # move that declaration to the first assignment if that assignment is ALSO at this scope. + topLevel = {(s.start_byte, s.end_byte) for s in _statements(body)} + deleteRanges = [] + replacements = [] + renames = {} + taken = set(reserved) + + for name in sorted(varDecls): + allAssigns = assignTargets.get(name, []) + wholeAssigns = [a for a in allAssigns if _isWholeVarAssign(a, src, name)] + # Fold only when EVERY assignment rebinds the whole variable (and there are >=2 of them). + # If any is a field/index write (`fd.model = ...`, `x[i] = ...`) the value is built in place, + # so keep naga's hoisted `var` declaration and leave the writes untouched. + if len(wholeAssigns) != len(allAssigns) or len(wholeAssigns) < 2: + continue + firstAssign = min(wholeAssigns, key=lambda node: node.start_byte) + firstStmt = _stmtContaining(body, firstAssign) + varStmt = varDecls[name] + if firstStmt is None: + continue + if (firstStmt.start_byte, firstStmt.end_byte) not in topLevel: + continue # first assignment is inside a nested block (e.g. an if branch); relocating + # the `var` decl there would narrow its scope and break references in sibling + # branches or after the block. Keep naga's hoisted declaration as-is. + rhs = _assignmentRhsText(firstAssign, src) + if not rhs: + continue + + clean = _pickLocalName(name, wgslParams, taken) + taken.add(clean) + deleteRanges.append(_lineRange(varStmt, src)) + indent = _lineIndent(firstStmt, src) + replacements.append(( + *_lineRange(firstStmt, src), + f"{indent}var {clean} = {rhs};\n")) + if name != clean: + renames[name] = clean + + if not deleteRanges and not replacements: + return fnText + + text = _applyEdits(fnText, deleteRanges, replacements) + text = _applyRenames(text, renames) + return text + + +def _dedentBlock(text, spaces=4): + '''Remove one indent level from non-empty lines.''' + prefix = " " * spaces + lines = [] + for line in text.splitlines(keepends=True): + if line.strip() and line.startswith(prefix): + lines.append(line[spaces:]) + else: + lines.append(line) + return "".join(lines) + + +def _unwrapRedundantCompounds(fnText): + '''Remove naga's `if (c) { { stmts } }` wrapper compounds.''' + while True: + src = fnText.encode("utf-8") + tree = _parser().parse(src) + fnDecl = _functionDecl(tree.root_node) + if fnDecl is None: + break + body = _compoundBody(fnDecl) + if body is None: + break + + best = None + stack = [body] + while stack: + node = stack.pop() + if node.type == "compound_statement": + inner = _compoundInnerWrapper(node) + if inner is not None: + wrapperStmt = inner + size = wrapperStmt.end_byte - wrapperStmt.start_byte + if best is None or size > best[0]: + best = (size, wrapperStmt, inner) + stack.extend(node.children) + + if best is None: + break + + _, wrapperStmt, inner = best + innerOpen, innerClose = _braceTokens(inner) + if not innerOpen or not innerClose: + break + + raw = src[innerOpen.end_byte:innerClose.start_byte].decode("utf-8") + body = _dedentBlock(raw.strip("\n")).rstrip() + content = (body + "\n") if body else "" + start, end = _lineRange(wrapperStmt, src) + fnText = _applyEdits(fnText, [], [(start, end, content)]) + return fnText + + +def _simplifyPtrParens(text): + '''`((*bsdf)).field` -> `(*bsdf).field`.''' + return re.sub(r"\(\(\*([^)]+)\)\)", r"(*\1)", text) + + +def _balancedParens(expr): + depth = 0 + for ch in expr: + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth < 0: + return False + return depth == 0 + + +def _unwrapAssignmentParens(text): + '''Drop a single outer paren pair around `let`/`var` rhs when balanced.''' + def repl(match): + indent, kind, name, expr = (match.group(1), match.group(2), + match.group(3), match.group(4)) + if _balancedParens(expr): + return f"{indent}{kind} {name} = {expr};" + return match.group(0) + + changed = True + while changed: + changed = False + new = re.sub( + r"^([ \t]*)\b(let|var)\s+(\w+)\s*=\s*\((.+)\);\s*$", + repl, + text, + flags=re.MULTILINE) + if new != text: + text = new + changed = True + return text + + +def _elseIfWrapper(elseClause): + '''If else { if (...) { ... } } return (elseClause, innerIf, expr). + + `expr` is the if-condition parenthesized_expression (parens included).''' + compound = None + for child in elseClause.children: + if child.type == "compound_statement": + compound = child + if compound is None: + return None + stmts = _stmtList(compound) + if len(stmts) != 1 or stmts[0].type != "if_statement": + return None + innerIf = stmts[0] + expr = next((c for c in innerIf.children + if c.type == "parenthesized_expression"), None) + if expr is None: + return None + return elseClause, innerIf, expr + + +def _flattenElseIfChains(fnText): + '''`} else { if (c) {` -> `} else if (c) {`.''' + while True: + src = fnText.encode("utf-8") + tree = _parser().parse(src) + fnDecl = _functionDecl(tree.root_node) + if fnDecl is None: + break + body = _compoundBody(fnDecl) + if body is None: + break + + candidate = None + stack = [body] + while stack: + node = stack.pop() + if node.type == "else_statement": + wrapped = _elseIfWrapper(node) + if wrapped is not None: + candidate = wrapped + break + stack.extend(node.children) + + if candidate is None: + break + + elseClause, innerIf, expr = candidate + # The `else` keyword is a sibling token BEFORE else_statement in this grammar (not part + # of the else node), so the rewrite must start at the keyword to avoid a duplicate `else`. + elseKw = elseClause.prev_sibling + cutStart = (elseKw.start_byte if elseKw is not None and elseKw.type == "else" + else elseClause.start_byte) + lineStart = src.rfind(b"\n", 0, elseClause.start_byte) + 1 + prefix = src[lineStart:elseClause.start_byte].decode("utf-8") + indent = re.match(r"^[ \t]*", prefix).group(0) + innerPart = src[expr.start_byte:innerIf.end_byte].decode("utf-8") + newElse = indent + "else if " + _dedentBlock(innerPart, 4) + fnText = (fnText[:cutStart] + newElse + + fnText[elseClause.end_byte:]) + return fnText + + +def _cleanupReadonlyLocals(fnText, glslParamNames=None): + '''Promote single-assignment `var` locals to `let` with GLSL-style names.''' + src = fnText.encode("utf-8") + tree = _parser().parse(src) + root = tree.root_node + # See _collapseParamShadows: errors from bare call statements are local; do not bail. + fnDecl = _functionDecl(root) + if fnDecl is None: + return fnText + body = _compoundBody(fnDecl) + if body is None: + return fnText + + wgslParams = _paramNames(fnDecl, src) + reserved = set(wgslParams) + if glslParamNames: + reserved.update(glslParamNames) + + varDecls = {} + for stmt in _statements(body): + name = _varDeclName(stmt, src) + # Only naga's UNINITIALIZED hoist (`var name: T;`) is a promotion candidate; an initialized + # declaration (`var i = 0i;`) is a real accumulator/counter and must be left intact. + if name and b"=" not in src[stmt.start_byte:stmt.end_byte]: + varDecls[name] = stmt + + assignTargets = _assignmentTargets(body, src) + # Byte ranges of the function body's direct (top-level) statements. The `var` declaration is + # always at this scope; converting it to a `let` at the assignment site is only valid when the + # assignment is ALSO at this scope (see the guard below). + topLevel = {(s.start_byte, s.end_byte) for s in _statements(body)} + deleteRanges = [] + replacements = [] + renames = {} + taken = set(reserved) + + for name in sorted(varDecls): + allAssigns = assignTargets.get(name, []) + # Promote to `let` only when the variable is assigned exactly once, as a whole (never a + # field/index write like `fd.model = ...`, which requires it to remain a mutable `var`). + if len(allAssigns) != 1 or not _isWholeVarAssign(allAssigns[0], src, name): + continue + assignNode = allAssigns[0] + assignStmt = _stmtContaining(body, assignNode) + varStmt = varDecls[name] + if assignStmt is None: + continue + rhs = _assignmentRhsText(assignNode, src) + if not rhs: + continue + skip = [(varStmt.start_byte, varStmt.end_byte), + (assignNode.start_byte, assignNode.end_byte)] + uses = _identUsesOutside(body, src, name, skip) + if uses == 0: + deleteRanges.append(_lineRange(varStmt, src)) + deleteRanges.append(_lineRange(assignStmt, src)) + continue + + if (assignStmt.start_byte, assignStmt.end_byte) not in topLevel: + continue # the single assignment is in a nested block (e.g. a loop `continuing` block or + # an if branch); relocating the declaration there as a `let` would move it out + # of the scope where the variable is read. Keep naga's hoisted declaration. + + clean = _pickLocalName(name, wgslParams, taken) + taken.add(clean) + deleteRanges.append(_lineRange(varStmt, src)) + indent = _lineIndent(assignStmt, src) + replacements.append(( + *_lineRange(assignStmt, src), + f"{indent}let {clean} = {rhs};\n")) + if name != clean: + renames[name] = clean + + if not deleteRanges and not replacements: + return fnText + + text = _applyEdits(fnText, deleteRanges, replacements) + text = _applyRenames(text, renames) + while True: + cleaned = re.sub(r"\n[ \t]*\n[ \t]*\n", "\n\n", text) + if cleaned == text: + break + text = cleaned + return text + + +def inlineSingleUseTemps(body): + '''Inline naga's single-use `let _eN = expr;` temporaries.''' + changed = True + while changed: + changed = False + for m in re.finditer(r"[ \t]*let\s+(_e\d+)\s*=\s*(.+?);\n", body): + name, expr = m.group(1), m.group(2) + if len(re.findall(r"\b" + re.escape(name) + r"\b", body)) - 1 == 1: + repl = "(" + expr + ")" if re.search(r"[-+*/ ]", expr.strip()) else expr + body = body[:m.start()] + body[m.end():] + body = re.sub(r"\b" + re.escape(name) + r"\b", lambda _m: repl, body, count=1) + changed = True + break + return body + + +def cleanupFunction(fnText, glslParamNames=None): + '''Run readability cleanup on one transpiled WGSL function.''' + if not cleanupAvailable(): + return fnText # no tree-sitter grammar: keep raw naga output (announced once by the caller) + try: + # Inline naga temps first so param-mutate patterns like `N_25 = -(_e39)` become + # `N_25 = -(N_25)` before tree-sitter param-shadow analysis runs. + text = inlineSingleUseTemps(fnText) + text = _collapseParamShadows(text, glslParamNames) + while True: + newText = _cleanupReadonlyLocals(text, glslParamNames) + if newText == text: + break + text = newText + text = _cleanupMutableLocals(text, glslParamNames) + text = _unwrapRedundantCompounds(text) + text = _flattenElseIfChains(text) + text = _simplifyPtrParens(text) + text = _unwrapAssignmentParens(text) + text = re.sub(r"\}\s+else\b", "} else", text) + text = re.sub(r"\n[ \t]+\n", "\n", text) + while True: + cleaned = re.sub(r"\n[ \t]*\n[ \t]*\n", "\n\n", text) + if cleaned == text: + break + text = cleaned + except Exception as exc: + print(f" WARN mxwgslcleanup: {exc}; keeping verbose naga output", file=sys.stderr) + text = fnText + return text diff --git a/source/MaterialXGenWgsl/tools/requirements-transpile.txt b/source/MaterialXGenWgsl/tools/requirements-transpile.txt new file mode 100644 index 0000000000..1fd72ab8b1 --- /dev/null +++ b/source/MaterialXGenWgsl/tools/requirements-transpile.txt @@ -0,0 +1,8 @@ +# Deps for WGSL post-process cleanup (mxwgslcleanup.py). +# tree-sitter-language-pack ships a precompiled WGSL grammar (and pulls the tree-sitter +# runtime), so no Node/tree-sitter-cli or vendored grammar is needed. +# +# OPTIONAL: only the readability cleanup pass needs this. Generation runs without it (emitting +# verbose but valid WGSL), so CI installs it best-effort -- interpreters with no wheel (e.g. some +# early/pre-release Python versions) still generate the library. +tree-sitter-language-pack>=1.13,<2 diff --git a/source/MaterialXGenWgsl/tools/skip_transpile.txt b/source/MaterialXGenWgsl/tools/skip_transpile.txt new file mode 100644 index 0000000000..3e1eb57e9b --- /dev/null +++ b/source/MaterialXGenWgsl/tools/skip_transpile.txt @@ -0,0 +1,43 @@ +# Hand-written genwgsl .wgsl files that must NOT be transpiled from genglsl. +# +# This is the authoritative list of hand-maintained WGSL, used by mxgenwgsl.py for two things: +# * the lib transpile pass skips any genwgsl/lib/*.wgsl listed here (naga can't express them); +# * `mxgenwgsl.py --clean` deletes every generated genwgsl *.wgsl EXCEPT the files listed here. +# +# Paths are relative to the repository root (a leading `libraries/` is optional). Blank lines and +# `#` comments are ignored. Node shaders skipped by SKIP_PATTERNS (image/hextiled/light) are also +# listed here so --clean retains them. + +# --- lights: light shaders take the dynamically-generated LightData struct --- +libraries/lights/genwgsl/mx_directional_light.wgsl +libraries/lights/genwgsl/mx_point_light.wgsl +libraries/lights/genwgsl/mx_spot_light.wgsl + +# --- stdlib nodes: texture nodes (naga's GLSL frontend has no sampler support) --- +libraries/stdlib/genwgsl/mx_hextiledimage.wgsl +libraries/stdlib/genwgsl/mx_hextilednormalmap.wgsl +libraries/stdlib/genwgsl/mx_image_color3.wgsl +libraries/stdlib/genwgsl/mx_image_color4.wgsl +libraries/stdlib/genwgsl/mx_image_float.wgsl +libraries/stdlib/genwgsl/mx_image_vector2.wgsl +libraries/stdlib/genwgsl/mx_image_vector3.wgsl +libraries/stdlib/genwgsl/mx_image_vector4.wgsl + +# --- stdlib lib helpers: whole dir maintained by hand (was HANDWRITTEN_LIB_DIRS = {"stdlib"}) --- +libraries/stdlib/genwgsl/lib/mx_flake.wgsl +libraries/stdlib/genwgsl/lib/mx_geometry.wgsl +libraries/stdlib/genwgsl/lib/mx_hextile.wgsl +libraries/stdlib/genwgsl/lib/mx_hsv.wgsl +libraries/stdlib/genwgsl/lib/mx_math.wgsl +libraries/stdlib/genwgsl/lib/mx_noise.wgsl +libraries/stdlib/genwgsl/lib/mx_transform_uv.wgsl +libraries/stdlib/genwgsl/lib/mx_transform_uv_vflip.wgsl + +# --- pbrlib lib helpers: sampler-bound helpers naga cannot express (was LIB_KEEP_HANDWRITTEN) --- +libraries/pbrlib/genwgsl/lib/mx_environment_fis.wgsl +libraries/pbrlib/genwgsl/lib/mx_environment_none.wgsl +libraries/pbrlib/genwgsl/lib/mx_environment_prefilter.wgsl +libraries/pbrlib/genwgsl/lib/mx_generate_albedo_table.wgsl +libraries/pbrlib/genwgsl/lib/mx_generate_prefilter_env.wgsl +libraries/pbrlib/genwgsl/lib/mx_shadow.wgsl +libraries/pbrlib/genwgsl/lib/mx_shadow_platform.wgsl diff --git a/source/MaterialXGenWgsl/tools/test_mxwgslcleanup.py b/source/MaterialXGenWgsl/tools/test_mxwgslcleanup.py new file mode 100644 index 0000000000..eccb5995e8 --- /dev/null +++ b/source/MaterialXGenWgsl/tools/test_mxwgslcleanup.py @@ -0,0 +1,237 @@ +"""Tests for mxwgslcleanup.py. + +These guard against the class of bug that is otherwise painful to find: a readability +pass that emits VALID WGSL but silently changes behaviour (e.g. reading an uninitialized +variable). Run with `pytest`, or standalone: `python test_mxwgslcleanup.py`. + +Only requires `tree-sitter-language-pack` (the transpile requirement) — no naga needed, +since the raw naga output for the tricky functions is embedded as fixtures below. +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import mxwgslcleanup as m + +# These tests need the tree-sitter WGSL grammar. On interpreters with no tree-sitter-language-pack +# wheel (e.g. Python 3.9) skip rather than fail. Only skip at collection when actually running under +# pytest (`pytest` already imported) -- a plain `python test_mxwgslcleanup.py` run must not call +# pytest.skip() outside a session; that path is handled by the __main__ guard below. +if not m.cleanupAvailable() and "pytest" in sys.modules: + import pytest + pytest.skip("tree-sitter-language-pack unavailable", allow_module_level=True) + + +# --- differential semantic invariant ---------------------------------------- + +def uninitialized_whole_reads(fn_text): + """Count locals that are read as a WHOLE value yet never assigned as a whole. + + A variable declared `var x: T;` (no initializer) that is passed to a call, returned, + or used on an assignment RHS, but only ever written via member/index stores + (`x.f = ...`) — or not written at all — reads its zero default. `collapse` removing a + `x = param` copy-in is exactly what turns a safe shadow into one of these. + + This is a per-function count. It is deliberately compared RAW-vs-CLEANED (not asserted + to be zero) so that legitimate struct builders (`var fd: FresnelData; fd.a=..; return fd;`) + — which have the same shape before and after cleanup — never trip it; only a NEW + occurrence introduced by cleanup is a regression. + """ + src = fn_text.encode("utf-8") + tree = m._parser().parse(src) + fn = m._functionDecl(tree.root_node) + if fn is None: + return 0 + body = m._compoundBody(fn) + if body is None: + return 0 + + uninit = {} + for stmt in m._statements(body): + name = m._varDeclName(stmt, src) + if name and b"=" not in src[stmt.start_byte:stmt.end_byte]: + uninit[name] = stmt + + assigns = m._assignmentTargets(body, src) + count = 0 + for name, decl in uninit.items(): + name_assigns = assigns.get(name, []) + has_whole = any(m._isWholeVarAssign(a, src, name) for a in name_assigns) + # Skip the decl and every assignment's lhs target; whatever `name` occurrences + # remain are genuine reads of the whole value. + skip = [(decl.start_byte, decl.end_byte)] + for a in name_assigns: + for child in a.children: + if child.type == "lhs_expression": + skip.append((child.start_byte, child.end_byte)) + reads = m._identUsesOutside(body, src, name, skip) + if reads > 0 and not has_whole: + count += 1 + return count + + +# --- fixtures: raw naga output (input to cleanup) ---------------------------- + +# The regression that caused this test to exist: `fd_8` is a copy of the `fd` param that is +# mutated by a FIELD write (`fd_8.refraction = true`) then read as a whole struct. collapse +# must keep the `fd_8 = fd` copy-in; deleting it left fd_8 zero-initialized -> broken glass. +FD_REFRACT_SHADOW = ( + "fn mx_surface_transmission(N_13: vec3f, V_7: vec3f, X_1: vec3f, alpha_15: vec2f, " + "distribution_1: i32, fd_7: FresnelData, tint: vec3f) -> vec3f {\n" + " var N_14: vec3f;\n" + " var V_8: vec3f;\n" + " var X_2: vec3f;\n" + " var alpha_16: vec2f;\n" + " var distribution_2: i32;\n" + " var fd_8: FresnelData;\n" + " var tint_1: vec3f;\n" + "\n" + " N_14 = N_13;\n" + " V_8 = V_7;\n" + " X_2 = X_1;\n" + " alpha_16 = alpha_15;\n" + " distribution_2 = distribution_1;\n" + " fd_8 = fd_7;\n" + " tint_1 = tint;\n" + " fd_8.refraction = true;\n" + " if false {\n" + " {\n" + " let _e33 = tint_1;\n" + " let _e34 = mx_square_vec3(_e33);\n" + " tint_1 = _e34;\n" + " }\n" + " }\n" + " let _e35 = N_14;\n" + " let _e36 = V_8;\n" + " let _e37 = X_2;\n" + " let _e38 = alpha_16;\n" + " let _e39 = distribution_2;\n" + " let _e40 = fd_8;\n" + " let _e41 = mx_environment_radiance(_e35, _e36, _e37, _e38, _e39, _e40);\n" + " let _e42 = tint_1;\n" + " return (_e41 * _e42);\n" + "}\n" +) +FD_REFRACT_PARAMS = ["N", "V", "X", "alpha", "distribution", "fd", "tint"] + +# A plain read-only param shadow: should collapse away entirely and restore the GLSL name. +READONLY_SHADOW = ( + "fn f(a_1: f32) -> f32 {\n" + " var a_2: f32;\n" + " a_2 = a_1;\n" + " let _e5 = a_2;\n" + " return (_e5 * 2.0);\n" + "}\n" +) +READONLY_PARAMS = ["a"] + +# A legitimate struct builder (NOT a param shadow): zero-init then fill field-by-field. +# cleanup must leave it working; the invariant must NOT flag it (same shape raw and cleaned). +STRUCT_BUILDER = ( + "fn mk() -> FresnelData {\n" + " var fd: FresnelData;\n" + " fd.ior = 1.5;\n" + " fd.model = 0i;\n" + " return fd;\n" + "}\n" +) + +# A param shadow reassigned in a NESTED scope only: copy-in must be kept (else read +# uninitialized in a sibling branch). Mirrors the tangent-X dielectric case. +NESTED_REASSIGN_SHADOW = ( + "fn g(X_1: vec3f, flag: bool) -> vec3f {\n" + " var X_2: vec3f;\n" + " X_2 = X_1;\n" + " if flag {\n" + " X_2 = (X_2 * 2.0);\n" + " }\n" + " let _e9 = X_2;\n" + " return _e9;\n" + "}\n" +) +NESTED_PARAMS = ["X", "flag"] + +FIXTURES = [ + ("fd_refract_field_shadow", FD_REFRACT_SHADOW, FD_REFRACT_PARAMS), + ("readonly_shadow", READONLY_SHADOW, READONLY_PARAMS), + ("struct_builder", STRUCT_BUILDER, None), + ("nested_reassign_shadow", NESTED_REASSIGN_SHADOW, NESTED_PARAMS), +] + + +# --- tests ------------------------------------------------------------------- + +def test_cleanup_introduces_no_uninitialized_reads(): + """Core guard: cleanup must never turn a safely-initialized variable into one that + is read while uninitialized. Would have caught the fd_8 glass-transmission bug.""" + for name, raw, params in FIXTURES: + cleaned = m.cleanupFunction(raw, params) + before = uninitialized_whole_reads(raw) + after = uninitialized_whole_reads(cleaned) + assert after <= before, ( + f"[{name}] cleanup introduced {after - before} uninitialized whole-value " + f"read(s) (raw={before}, cleaned={after}).\n--- cleaned ---\n{cleaned}") + + +def test_field_mutated_shadow_keeps_copy_in(): + """Regression: the FresnelData copy-in must survive so the struct is fully seeded + before `fd.refraction = true` and the whole-struct read.""" + cleaned = m.cleanupFunction(FD_REFRACT_SHADOW, FD_REFRACT_PARAMS) + assert uninitialized_whole_reads(cleaned) == 0, cleaned + # The struct is read whole; whatever name it keeps, it must be whole-assigned first. + src = cleaned.encode("utf-8") + tree = m._parser().parse(src) + body = m._compoundBody(m._functionDecl(tree.root_node)) + assigns = m._assignmentTargets(body, src) + # find the FresnelData local decl name + fd_name = None + for stmt in m._statements(body): + n = m._varDeclName(stmt, src) + if n and b"FresnelData" in src[stmt.start_byte:stmt.end_byte]: + fd_name = n + assert fd_name is not None, cleaned + assert any(m._isWholeVarAssign(a, src, fd_name) for a in assigns.get(fd_name, [])), ( + f"FresnelData local '{fd_name}' has no whole-variable initializer:\n{cleaned}") + + +def test_readonly_shadow_collapses_and_renames(): + cleaned = m.cleanupFunction(READONLY_SHADOW, READONLY_PARAMS) + assert "a_2" not in cleaned, cleaned # shadow removed + assert "a_1" not in cleaned, cleaned # naga name restored to GLSL 'a' + assert "(a * 2.0)" in cleaned, cleaned + + +def test_struct_builder_unchanged_shape(): + """A field-filled local struct is a legitimate zero-init pattern; it should still + read whole (count 1) in both raw and cleaned — proving no false positive.""" + assert uninitialized_whole_reads(STRUCT_BUILDER) == 1 + cleaned = m.cleanupFunction(STRUCT_BUILDER, None) + assert uninitialized_whole_reads(cleaned) == 1, cleaned + + +def test_nested_reassign_keeps_copy_in(): + cleaned = m.cleanupFunction(NESTED_REASSIGN_SHADOW, NESTED_PARAMS) + assert uninitialized_whole_reads(cleaned) == 0, cleaned + + +def test_cleanup_is_idempotent(): + for _name, raw, params in FIXTURES: + once = m.cleanupFunction(raw, params) + twice = m.cleanupFunction(once, params) + assert once == twice, f"not idempotent for {_name}:\n{once!r}\n!=\n{twice!r}" + + +if __name__ == "__main__": + if not m.cleanupAvailable(): + print("SKIP test_mxwgslcleanup: tree-sitter-language-pack unavailable") + sys.exit(0) + failed = 0 + for fn in sorted(k for k in dict(globals()) if k.startswith("test_")): + try: + globals()[fn]() + print(f"PASS {fn}") + except AssertionError as exc: + failed += 1 + print(f"FAIL {fn}: {exc}") + print(f"\n{'OK' if not failed else str(failed) + ' FAILED'}") + sys.exit(1 if failed else 0) diff --git a/source/MaterialXTest/CMakeLists.txt b/source/MaterialXTest/CMakeLists.txt index 2cb7c1330a..7d408050e5 100644 --- a/source/MaterialXTest/CMakeLists.txt +++ b/source/MaterialXTest/CMakeLists.txt @@ -46,7 +46,7 @@ endif() add_subdirectory(MaterialXGenShader) target_link_libraries(MaterialXTest MaterialXGenShader) -if(MATERIALX_BUILD_GEN_GLSL OR MATERIALX_BUILD_GEN_OSL OR MATERIALX_BUILD_GEN_MDL OR MATERIALX_BUILD_GEN_MSL OR MATERIALX_BUILD_GEN_SLANG) +if(MATERIALX_BUILD_GEN_GLSL OR MATERIALX_BUILD_GEN_OSL OR MATERIALX_BUILD_GEN_MDL OR MATERIALX_BUILD_GEN_MSL OR MATERIALX_BUILD_GEN_SLANG OR MATERIALX_BUILD_GEN_WGSL) if(MATERIALX_BUILD_GEN_GLSL) add_subdirectory(MaterialXGenGlsl) target_link_libraries(MaterialXTest MaterialXGenGlsl) @@ -55,6 +55,10 @@ if(MATERIALX_BUILD_GEN_GLSL OR MATERIALX_BUILD_GEN_OSL OR MATERIALX_BUILD_GEN_MD add_subdirectory(MaterialXGenSlang) target_link_libraries(MaterialXTest MaterialXGenSlang) endif() + if(MATERIALX_BUILD_GEN_WGSL) + add_subdirectory(MaterialXGenWgsl) + target_link_libraries(MaterialXTest MaterialXGenWgsl) + endif() if(MATERIALX_BUILD_GEN_OSL) add_subdirectory(MaterialXGenOsl) target_link_libraries(MaterialXTest MaterialXGenOsl) diff --git a/source/MaterialXTest/MaterialXGenGlsl/GenGlsl.cpp b/source/MaterialXTest/MaterialXGenGlsl/GenGlsl.cpp index e99d83a452..a39e2600f9 100644 --- a/source/MaterialXTest/MaterialXGenGlsl/GenGlsl.cpp +++ b/source/MaterialXTest/MaterialXGenGlsl/GenGlsl.cpp @@ -19,7 +19,6 @@ #include #include #include -#include #include namespace mx = MaterialX; @@ -141,8 +140,7 @@ enum class GlslType Essl, Glsl, GlslLayout, - GlslVulkan, - GlslWgsl + GlslVulkan }; static void generateGlslCode(GlslType type) @@ -163,10 +161,6 @@ static void generateGlslCode(GlslType type) { generator = mx::VkShaderGenerator::create(); } - else if (type == GlslType::GlslWgsl) - { - generator = mx::WgslShaderGenerator::create(); - } else { generator = mx::GlslShaderGenerator::create(); @@ -177,8 +171,7 @@ static void generateGlslCode(GlslType type) { GlslType::Essl, "essl" }, { GlslType::Glsl, "glsl" }, { GlslType::GlslLayout, "glsl_layout" }, - { GlslType::GlslVulkan, "glsl_vulkan" }, - { GlslType::GlslWgsl , "glsl_wgsl" } + { GlslType::GlslVulkan, "glsl_vulkan" } }; const mx::FilePath logPath("genglsl_" + TYPE_NAME_MAP.at(type) + "_generate_test.txt"); GlslShaderGeneratorTester tester(generator, testRootPaths, searchPath, logPath, false); @@ -215,8 +208,3 @@ TEST_CASE("GenShader: Vulkan GLSL Shader Generation", "[genglsl]") { generateGlslCode(GlslType::GlslVulkan); } - -TEST_CASE("GenShader: Wgsl GLSL Shader Generation", "[genglsl]") -{ - generateGlslCode(GlslType::GlslWgsl); -} diff --git a/source/MaterialXTest/MaterialXGenWgsl/CMakeLists.txt b/source/MaterialXTest/MaterialXGenWgsl/CMakeLists.txt new file mode 100644 index 0000000000..f42e36e06d --- /dev/null +++ b/source/MaterialXTest/MaterialXGenWgsl/CMakeLists.txt @@ -0,0 +1,9 @@ +file(GLOB_RECURSE source "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp") +file(GLOB_RECURSE headers "${CMAKE_CURRENT_SOURCE_DIR}/*.h") + +target_sources(MaterialXTest PUBLIC ${source} ${headers}) + +add_tests("${source}") + +assign_source_group("Source Files" ${source}) +assign_source_group("Header Files" ${headers}) diff --git a/source/MaterialXTest/MaterialXGenWgsl/GenWgsl.cpp b/source/MaterialXTest/MaterialXGenWgsl/GenWgsl.cpp new file mode 100644 index 0000000000..347afd8e1d --- /dev/null +++ b/source/MaterialXTest/MaterialXGenWgsl/GenWgsl.cpp @@ -0,0 +1,88 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#include + +#include +#include + +#include +#include +#include + +#include +#include + +#include + +#include + +namespace mx = MaterialX; + +TEST_CASE("GenShader: WGSL Syntax Check", "[genwgsl]") +{ + mx::TypeSystemPtr typeSystem = mx::TypeSystem::create(); + mx::SyntaxPtr syntax = mx::WgslSyntax::create(typeSystem); + + REQUIRE(syntax->getTypeName(mx::Type::FLOAT) == "f32"); + REQUIRE(syntax->getTypeName(mx::Type::COLOR3) == "vec3f"); + REQUIRE(syntax->getTypeName(mx::Type::VECTOR4) == "vec4f"); + REQUIRE(syntax->getTypeName(mx::Type::MATRIX44) == "mat4x4f"); + REQUIRE(syntax->getSourceFileExtension() == ".wgsl"); +} + +TEST_CASE("GenShader: WGSL Target Registration", "[genwgsl]") +{ + mx::ShaderGeneratorPtr generator = mx::WgslShaderGenerator::create(); + REQUIRE(generator->getTarget() == "genwgsl"); +} + +TEST_CASE("GenShader: WGSL Shader Generation", "[genwgsl]") +{ + mx::FileSearchPath searchPath = mx::getDefaultDataSearchPath(); + + mx::DocumentPtr doc = mx::createDocument(); + mx::loadLibraries({ "libraries" }, searchPath, doc); + + // Load a representative lit surface material (standard_surface). + mx::FilePath examplePath = searchPath.find("resources/Materials/Examples/StandardSurface/standard_surface_default.mtlx"); + REQUIRE(!examplePath.isEmpty()); + mx::readFromXmlFile(doc, examplePath); + + mx::ShaderGeneratorPtr generator = mx::WgslShaderGenerator::create(); + mx::GenContext context(generator); + context.registerSourceCodeSearchPath(searchPath); + context.getOptions().hwSpecularEnvironmentMethod = mx::SPECULAR_ENVIRONMENT_FIS; + context.getOptions().hwMaxActiveLightSources = 1; + + std::vector renderables = mx::findRenderableElements(doc); + REQUIRE(!renderables.empty()); + + size_t generated = 0; + for (const mx::TypedElementPtr& element : renderables) + { + const std::string name = mx::createValidName(element->getNamePath()); + + mx::ShaderPtr shader; + REQUIRE_NOTHROW(shader = generator->generate(name, element, context)); + REQUIRE(shader != nullptr); + + const std::string& vertexCode = shader->getSourceCode(mx::Stage::VERTEX); + const std::string& pixelCode = shader->getSourceCode(mx::Stage::PIXEL); + + REQUIRE(!vertexCode.empty()); + REQUIRE(!pixelCode.empty()); + REQUIRE(vertexCode.find("@vertex") != std::string::npos); + REQUIRE(vertexCode.find("fn vertexMain") != std::string::npos); + REQUIRE(pixelCode.find("@fragment") != std::string::npos); + REQUIRE(pixelCode.find("fn fragmentMain") != std::string::npos); + + // Write the generated WGSL to files for manual inspection / external validation. + std::ofstream(name + ".vertex.wgsl") << vertexCode; + std::ofstream(name + ".pixel.wgsl") << pixelCode; + generated++; + } + REQUIRE(generated > 0); +} diff --git a/source/PyMaterialX/CMakeLists.txt b/source/PyMaterialX/CMakeLists.txt index cafbb79245..bd07db8d42 100644 --- a/source/PyMaterialX/CMakeLists.txt +++ b/source/PyMaterialX/CMakeLists.txt @@ -37,7 +37,7 @@ endif() add_subdirectory(PyMaterialXCore) add_subdirectory(PyMaterialXFormat) -if (MATERIALX_BUILD_GEN_GLSL OR MATERIALX_BUILD_GEN_OSL OR MATERIALX_BUILD_GEN_MDL OR MATERIALX_BUILD_GEN_MSL OR MATERIALX_BUILD_GEN_SLANG) +if (MATERIALX_BUILD_GEN_GLSL OR MATERIALX_BUILD_GEN_OSL OR MATERIALX_BUILD_GEN_MDL OR MATERIALX_BUILD_GEN_MSL OR MATERIALX_BUILD_GEN_SLANG OR MATERIALX_BUILD_GEN_WGSL) add_subdirectory(PyMaterialXGenShader) if (MATERIALX_BUILD_GEN_GLSL) add_subdirectory(PyMaterialXGenGlsl) @@ -54,6 +54,9 @@ if (MATERIALX_BUILD_GEN_GLSL OR MATERIALX_BUILD_GEN_OSL OR MATERIALX_BUILD_GEN_M if (MATERIALX_BUILD_GEN_SLANG) add_subdirectory(PyMaterialXGenSlang) endif() + if (MATERIALX_BUILD_GEN_WGSL) + add_subdirectory(PyMaterialXGenWgsl) + endif() endif() if (MATERIALX_BUILD_RENDER) add_subdirectory(PyMaterialXRender) @@ -87,7 +90,8 @@ if (MATERIALX_BUILD_DOCS) # are integrated prior to building the Python extension modules. foreach(_py_mod IN ITEMS PyMaterialXCore PyMaterialXFormat PyMaterialXGenShader PyMaterialXGenGlsl PyMaterialXGenMsl PyMaterialXGenMdl - PyMaterialXGenOsl PyMaterialXRender PyMaterialXRenderGlsl + PyMaterialXGenOsl PyMaterialXGenSlang PyMaterialXGenWgsl + PyMaterialXRender PyMaterialXRenderGlsl PyMaterialXRenderOsl PyMaterialXRenderMsl) if(TARGET ${_py_mod}) add_dependencies(${_py_mod} PyBindDocs) diff --git a/source/PyMaterialX/PyMaterialXGenGlsl/PyGlslShaderGenerator.cpp b/source/PyMaterialX/PyMaterialXGenGlsl/PyGlslShaderGenerator.cpp index 87726352ff..ae28d9890f 100644 --- a/source/PyMaterialX/PyMaterialXGenGlsl/PyGlslShaderGenerator.cpp +++ b/source/PyMaterialX/PyMaterialXGenGlsl/PyGlslShaderGenerator.cpp @@ -9,7 +9,6 @@ #include #include #include -#include #include #include @@ -33,10 +32,6 @@ namespace { return mx::VkShaderGenerator::create(); } - mx::ShaderGeneratorPtr WgslShaderGenerator_create() - { - return mx::WgslShaderGenerator::create(); - } } void bindPyGlslShaderGenerator(py::module& mod) @@ -78,14 +73,3 @@ void bindPyVkShaderGenerator(py::module& mod) .def("getTarget", &mx::VkShaderGenerator::getTarget) .def("getVersion", &mx::VkShaderGenerator::getVersion); } - -// Glsl Wgsl shader generator bindings - -void bindPyWgslShaderGenerator(py::module& mod) -{ - py::class_(mod, "WgslShaderGenerator") - .def_static("create", &WgslShaderGenerator_create) - .def("generate", &mx::WgslShaderGenerator::generate) - .def("getTarget", &mx::WgslShaderGenerator::getTarget) - .def("getVersion", &mx::WgslShaderGenerator::getVersion); -} diff --git a/source/PyMaterialX/PyMaterialXGenGlsl/PyModule.cpp b/source/PyMaterialX/PyMaterialXGenGlsl/PyModule.cpp index 4e020301db..448e1f4321 100644 --- a/source/PyMaterialX/PyMaterialXGenGlsl/PyModule.cpp +++ b/source/PyMaterialX/PyMaterialXGenGlsl/PyModule.cpp @@ -11,7 +11,6 @@ void bindPyGlslShaderGenerator(py::module& mod); void bindPyGlslResourceBindingContext(py::module &mod); void bindPyEsslShaderGenerator(py::module& mod); void bindPyVkShaderGenerator(py::module& mod); -void bindPyWgslShaderGenerator(py::module& mod); PYBIND11_MODULE(PyMaterialXGenGlsl, mod) { @@ -25,5 +24,4 @@ PYBIND11_MODULE(PyMaterialXGenGlsl, mod) bindPyEsslShaderGenerator(mod); bindPyVkShaderGenerator(mod); - bindPyWgslShaderGenerator(mod); } diff --git a/source/PyMaterialX/PyMaterialXGenWgsl/CMakeLists.txt b/source/PyMaterialX/PyMaterialXGenWgsl/CMakeLists.txt new file mode 100644 index 0000000000..c3e1d007db --- /dev/null +++ b/source/PyMaterialX/PyMaterialXGenWgsl/CMakeLists.txt @@ -0,0 +1,26 @@ +file(GLOB pymaterialxgenwgsl_source "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp") +file(GLOB pymaterialxgenwgsl_headers "${CMAKE_CURRENT_SOURCE_DIR}/*.h") + +pybind11_add_module(PyMaterialXGenWgsl SHARED ${PYBIND11_MODULE_FLAGS} ${pymaterialxgenwgsl_source} ${pymaterialxgenwgsl_headers}) + +if(APPLE) + set_target_properties(PyMaterialXGenWgsl PROPERTIES CXX_VISIBILITY_PRESET "default") +endif() + +set_target_properties( + PyMaterialXGenWgsl + PROPERTIES + OUTPUT_NAME PyMaterialXGenWgsl + COMPILE_FLAGS "${EXTERNAL_COMPILE_FLAGS}" + LINK_FLAGS "${EXTERNAL_LINK_FLAGS}" + INSTALL_RPATH "${MATERIALX_UP_TWO_RPATH}" + DEBUG_POSTFIX "${MATERIALX_PYTHON_DEBUG_POSTFIX}") + +target_link_libraries( + PyMaterialXGenWgsl + PUBLIC PyMaterialXGenShader + MaterialXGenWgsl + PRIVATE ${CMAKE_DL_LIBS}) + +install(TARGETS PyMaterialXGenWgsl + DESTINATION "${MATERIALX_PYTHON_FOLDER_NAME}") diff --git a/source/PyMaterialX/PyMaterialXGenWgsl/PyModule.cpp b/source/PyMaterialX/PyMaterialXGenWgsl/PyModule.cpp new file mode 100644 index 0000000000..3963bb7a8e --- /dev/null +++ b/source/PyMaterialX/PyMaterialXGenWgsl/PyModule.cpp @@ -0,0 +1,19 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#include + +namespace py = pybind11; + +void bindPyWgslShaderGenerator(py::module& mod); + +PYBIND11_MODULE(PyMaterialXGenWgsl, mod) +{ + mod.doc() = "Shader generation using the WebGPU Shading Language (WGSL)."; + + PYMATERIALX_IMPORT_MODULE(PyMaterialXGenShader); + + bindPyWgslShaderGenerator(mod); +} diff --git a/source/PyMaterialX/PyMaterialXGenWgsl/PyWgslShaderGenerator.cpp b/source/PyMaterialX/PyMaterialXGenWgsl/PyWgslShaderGenerator.cpp new file mode 100644 index 0000000000..47db7c5147 --- /dev/null +++ b/source/PyMaterialX/PyMaterialXGenWgsl/PyWgslShaderGenerator.cpp @@ -0,0 +1,30 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#include + +#include +#include +#include + +namespace py = pybind11; +namespace mx = MaterialX; + +namespace +{ + mx::ShaderGeneratorPtr WgslShaderGenerator_create() + { + return mx::WgslShaderGenerator::create(); + } +} + +void bindPyWgslShaderGenerator(py::module& mod) +{ + py::class_(mod, "WgslShaderGenerator") + .def_static("create", &WgslShaderGenerator_create) + .def("generate", &mx::WgslShaderGenerator::generate) + .def("getTarget", &mx::WgslShaderGenerator::getTarget) + .def("getVersion", &mx::WgslShaderGenerator::getVersion); +}