Sharing Shader Code Between the C++ Vulkan Engine and the Rust WebGPU Renderer#
Slang is the single source of truth. One
.slang source compiles to SPIR-V (Vulkan/C++) and WGSL (WebGPU/Rust),
including shared entry points where the two renderers’ passes coincide.
The pipeline (current)#
shader-build-pipeline.md owns the build wiring,
compile commands, output directories, staleness rules and fast iteration. On
the Rust side its combined WGSL emit replaces the crates’ hand-written WGSL;
their include_str! calls are unchanged and now read Slang-emitted text.
histogram.wgsl is the one deliberate holdout (WGSL fallback policy below).
Why Slang (not GLSL, not naga/WGSL-as-source)#
WGSL used to be the shared-math source, translated by naga into GLSL the C++
engine #included (Historical note below). That shared functions but not
entry points: the generated SPIR-V/GLSL carried WebGPU binding decorations
that do not match the Vulkan engine’s descriptor layout.
Slang’s parameter binding ([vk::binding(i,s)] → @binding(i) @group(s) in
WGSL) aligns the two binding models, so entry points are shareable too. Slang
also targets SPIR-V directly, ray tracing pipeline and ray query included.
WebGPU/WGSL has neither, so those shaders are Slang only for language unity on
the C++ side and emit one target.
Target status (verified against slangc in VulkanSDK 1.4.350.0)#
Target |
Status |
Notes |
|---|---|---|
SPIR-V (Vulkan) |
stable |
Rasterization, compute, ray tracing pipeline, ray query, descriptor sets, push constants |
WGSL (WebGPU) |
experimental |
Functional (official |
WGSL fallback policy: a shared shader that Slang cannot express for the
WGSL target at all (not merely emit) gets hand-written WGSL and no Slang
source, rather than a Slang source nothing on the C++ side consumes that would
still have to be kept in sync. histogram.wgsl is the one case today
(rationale in shader-build-pipeline.md).
Architecture: Slang-native, not #include#
Slang is module- and entry-point-centric: a no-entry-point math module cannot
be emitted to GLSL/WGSL text, and imported functions are name-mangled in the
emit, so sharing goes through Slang’s module system, not textual #include:
Shared math lives in modules under
Resources/ShadersSlang/common/. Four (aces.slang,brdf.slang,noise.slang,fullscreen.slang) are imported on both targets, so both renderers share them. The rest are shared within one target: all butsky_model.slangon the C++/SPIR-V side, across its raster, deferred, shadow-map, ray tracing and path tracing paths. The shared-module-targets table below is the exact, gated mapping.Entry points
importthe math modules and compile to the target(s) their renderer needs. Slang links modules internally, so mangling is a non-issue.Where the passes genuinely differ (they composite differently), each renderer keeps its own entry points over the same imported math.
What is wired today#
Shared math modules (imported by entry points, never emitted directly):
common/aces.slang— ACES filmic tonemap (Narkowicz 2015) + exact IEC 61966-2-1 sRGB encode.common/brdf.slang— the Epic Games / Unreal 4 microfacet PBR model (distribution_ggx,geometry_smith,fresnel_schlick,brdf_direct), used by both renderers’ forward/RT shading.common/noise.slang— 3D simplex noise + fractal Brownian motion, used by both renderers’ cloud/procedural passes.common/fullscreen.slang— the fullscreen-triangle vertex trick (vid/2*4-1), used by every fullscreen pass on both sides. Itsuvis the one per-target difference in shared code: uv (0,0) is always the target’s top-left texel, but clip-space y = -1 is the top row in Vulkan and the bottom row in WebGPU, sofullscreen_vspicks the V formula with__target_switch(spirv:v = (y + 1) / 2; every other target:v = 1 - (y + 1) / 2). The WGSL emit is byte-identical to the single formula it replaced.fullscreen_uv_to_ndcis its exact inverse, switched the same way, for the passes that reconstruct a position from the uv (deferred.slangon SPIR-V,ssao.slangon WGSL).BuildIntegrity.NoShaderRedeclaresTheFullscreenUvMappingfails on a hand-written copy of either direction. A wrong V mirrors the frame about its horizontal centre line, which a vertically symmetric test scene cannot show;GoldenRender.WorldUpIsScreenUp(C++) andworld_up_is_screen_up(OxidANTtests/headless.rs) pin it.common/material_fetch.slang— theobjectDescriptionbinding and thefetch_object_description/fetch_materiallookups on it. Spirv only: the raster (rasterizer.slang,deferred.slang,shadow_map.slang) and closest-hit/any-hit ray tracing (raytrace.rchit.slang,raytrace.rahit.slang) entry points.path_tracing.slangdeclares its ownobjectDescriptionbinding, so it cannot import this module.common/material_rules.slang— the binding-free glTF material rules on the fetchedObjMaterial:material_roughness,material_metallic_roughness(glTF SS3.9.2 G=roughness/B=metallic channel swizzle) andalpha_masked_out(alphaMode MASK). Split out ofmaterial_fetch.slangsopath_tracing.slangcan call them, as withbase_color.slangbelow. Spirv only, every C++ shading path (raster, deferred, shadow map, ray tracing, path tracing).common/material_textures.slang— the one declaration of thetextures[]/textureSamplers[]arrays (TEXTURES_BINDING/SAMPLER_BINDING), plus explicit-LOD (SampleLevel) and implicit-LOD (Sample) helpers for the alpha (map_d), normal, metallic-roughness and emissive slots. Both LOD shapes share one module because an implicit-LOD helper an entry point does not call is dead-code-eliminated before Slang’s capability checking, so a compute/ray-tracing importer that calls only the explicit-LOD half never picks up a fragment-only capability. The base-colour slot is not here: its sampling stays inline at each call site, next to the per-shader control flow (albedo assembly, discard, G-buffer packing). Spirv only:rasterizer.slang,deferred.slang,rasterizer/shadows/shadow_map.slang,raytrace.rchit.slangandalpha_test.slang;path_tracing.slanggets it throughalpha_test.slangand also imports it directly for its own base-colour sample.common/cascaded_shadow.slang— PCF cascaded shadow-map sampling (calc_cascaded_shadow). Spirv only: the raster and deferred lighting paths (rasterizer.slang,deferred.slang).common/base_color.slang—base_color(glTF base colour =baseColorFactor * sampled texture) andtransform_uv(KHR_texture_transform), in a bindingless module so every spirv shading path can import it. It keepsrasterizer/rasterizer.slang,deferred/deferred.slang,raytracing/raytrace.rchit.slangandpath_tracing/path_tracing.slangin agreement with the referenceprim.base_color * baseSampleinforward/forward.slang(wgsl, not an importer); before it, those four droppedbaseColorFactorwhenever a material also had a texture. The alpha half travels separately:fromGltfMaterialputsbaseColorFactor[0..2]indiffuseandbaseColorFactor[3]indissolve, whichalpha_masked_out()multiplies into the sampled alpha (theObjMaterialtable inmodel-loading.md). Spirv only.common/emission.slang— the glTF emissive rule (material_emission:emissiveFactor* sampledemissiveTexture, or the factor alone without a texture). Spirv only, all four raster/RT shading paths (rasterizer.slang,deferred.slang,raytrace.rchit.slang,path_tracing.slang).common/normal_map.slang— glTF tangent-space normal mapping (apply_normal_map, glTF SS3.9.3). Spirv only, the same four paths asemission.slang;forward/forward.slang’sfs_main(wgsl) reimplements the same math locally.common/alpha_test.slang— the glTF MASK alpha test for ray queries:raytrace.rahit.slang’s any-hit shader andpath_tracing.slang’s ray-query candidate loop (which has no any-hit stage of its own). Spirv only.common/sky_model.slang— the analytic sky’s horizon/zenith/ground gradient (sky_gradient), sun disk (sun_disk) and diffuse hemisphere irradiance approximation (hemisphere_irradiance), shared bysky/sky.slang(the Rust/WebGPU background) andforward/forward.slang(the analytic IBL fallback, whose reflections and irradiance must agree with the background). There is deliberately no combinedsky_radiance: each caller takes its light direction/intensity from a different uniform block, so each keeps a thin local wrapper oversky_gradient+sun_disk.tests/sky_constants.rspins the three gradient constants a third time on the Rust CPU side (render::ibl::SKY_ZENITH/SKY_HORIZON/SKY_GROUND, consumed byEnvironmentImage::sky’s panorama).
Entry-point shader targets: every Slang entry-point source below compiles
to exactly one target, so none is shared between the two renderers at the
entry-point level. Only the four both math modules and the CI guards below
cross targets. tonemap/tonemap.slang (WGSL) and post/post.slang (SPIR-V)
look like one pass but are separate sources: the C++ side’s post/post.slang
also composites clouds via push constants, so it never merged with the Rust
tonemap shader.
File |
Target |
|---|---|
|
wgsl |
|
spirv |
|
spirv |
|
spirv |
|
wgsl |
|
wgsl |
|
wgsl |
|
wgsl |
|
wgsl |
|
spirv |
|
spirv |
|
spirv |
|
spirv |
|
spirv |
|
spirv |
|
spirv |
|
spirv |
|
spirv |
|
wgsl |
|
spirv |
|
wgsl |
|
wgsl |
|
wgsl |
CI guards: tests/brdf_test.slang and tests/noise_test.slang each
import a shared math module and dual-emit to SPIR-V + WGSL, so a change that
breaks either target’s compile fails the manifest run in
Build-SlangShaders.ps1 before it reaches either renderer. The table above
excludes them (see its gating test). Only brdf and noise are guarded for
dual emit; a spirv-only module has nothing to guard until it gains a real wgsl
consumer, which the table below makes checkable.
Shared-module targets: the per-common/-module counterpart of the table
above, computed the same way (an import-graph walk over shader-manifest.json)
and gated by the same kind of test. tests/*.slang count as real consumers,
which is why brdf and noise show both although noise has no production
wgsl consumer yet. (unused) would mark a module under common/ that nothing
imports.
Module |
Target |
|---|---|
|
both |
|
spirv |
|
spirv |
|
both |
|
spirv |
|
spirv |
|
both |
|
spirv |
|
spirv |
|
spirv |
|
both |
|
spirv |
|
spirv |
|
spirv |
|
spirv |
|
wgsl |
Status: all C++ shaders are on Slang SPIR-V (all 8 loading sites), and all
Rust shaders on Slang-emitted WGSL except histogram.wgsl (WGSL fallback
policy above). The migration is complete.
Which C++ shading path reads which ObjMaterial field#
A new ObjMaterial field is not done when one shading path reads it:
BuildIntegrity.EveryObjMaterialFieldIsReadByAShader only asks whether some
.slang source mentions material.<field>, so a field read by
rasterizer.slang and silently dropped by path_tracing.slang still passes.
This table is the per-path answer: one row per ObjMaterial member
(common/scene_types.slang), one column per C++ shading path
(deferred.slang’s is geometry_fs_main, raytrace.rchit.slang’s
rchit_main, path_tracing.slang’s path_tracing_main); the two alpha-only
consumers shadow_map.slang/alpha_test.slang share a column, since neither
shades a colour. A cell names the expression or helper that reads the field on
that path, or says “not read” and why. The asymmetries it pins, so they stop
being rediscovered one field at a time:
metallicis a full factor-or-texture read on three paths, but onpath_tracing.slangonly the metallic-roughness texture branch touches it (material_textures.slang’s comment says why that path skipsresolved_metallic_roughness_lod0()).shininessis only reachable throughmaterial_roughness()’s negative-roughnessfallback (“(fallback)” in its row).dissolveis read only insidealpha_masked_out(), so a path with no MASK alpha test of its own (raytrace.rchit.slang,path_tracing.slang’s own shading; their MASK test runs earlier, inalpha_test.slang’sray_hit_masked_out()) never touches it.
Member |
|
|
|
|
|
|---|---|---|---|---|---|
|
|
|
|
|
not read — alpha testing only discards a fragment, never shades a colour |
|
|
|
|
|
not read — no lighting |
|
|
|
|
not read — no roughness/specular term at all (only the metallic half of |
not read — no lighting |
|
|
|
not read — the MASK cut-out for ray-traced hits runs in the any-hit shader ( |
not read — candidates are alpha-tested earlier, by |
|
|
|
|
|
|
|
|
|
|
not read — see |
not read — see |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
not read — no lighting |
|
|
|
|
not read — only the metallic half ( |
not read — no lighting |
|
|
|
|
|
not read — no lighting |
|
|
|
|
|
not read — no normal mapping |
|
|
|
|
|
not read — no normal mapping |
|
|
|
|
|
not read — no lighting |
|
|
|
|
|
not read — no normal mapping |
|
|
|
|
|
not read — no normal mapping |
|
|
|
|
|
not read — no lighting |
|
|
|
|
|
not read — no lighting |
|
|
|
|
|
not read — no lighting |
|
|
|
|
|
not read — no lighting |
|
|
|
|
|
not read — MASK alpha testing runs regardless of |
|
|
|
not read — see |
not read — see |
|
Beyond shaders#
The larger cross-renderer items on the roadmap
(OxidANT’s third_party/OxidANT/crates/webgpu_renderer/docs/webgpu-renderer-roadmap.md, Phase G):
Shared assets — OBJ→glTF conversion, so both renderers load the same scenes (the C++ engine’s
Resources/Modelsbecomes directly usable).Side-by-side comparison harness — same scene, same camera, diff the Vulkan and WebGPU screenshots. With shared BRDF math the images should match closely, so the diff becomes a regression net for both renderers.
Historical note: the retired naga/WGSL-export route (2026-07-20 to 2026-07-30)#
Before Slang, WGSL was to stay the source of truth:
cargo run -p kataglyphis_webgpu_renderer --example export_shaders (naga)
translated the Rust renderer’s hand-written WGSL into SPIR-V/GLSL for the C++
side, opt-in via a -ExportWgslShaders flag on Build-Windows.ps1 /
Build-Windows-Container.ps1, output to the gitignored
Resources/Shaders/generated/. It shared functions (a
Resources/Shaders/generated/aces.glsl/brdf.glsl #include POC worked) but
never entry points, for the binding-model mismatch above; that limit
motivated the Slang migration.
Retired 2026-07-30: nothing in the C++ engine consumed the exported output
(grep -rn "\.spv" Src/ shows every SPIR-V load coming from
Resources/ShadersSlang/build/spirv/), so the flag, its build-script plumbing
and the Resources/Shaders/generated/ gitignore entry were removed. The
export_shaders naga example stays in the Rust crate upstream, harmless and
still useful for inspecting naga’s WGSL→SPIR-V output; this repo no longer
treats it as the sharing route.