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 wgpu-html5 example uses it). No ray tracing, ray query, mesh/tessellation/geometry shaders, wave intrinsics, f64, i8/u8

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 but sky_model.slang on 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 import the 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. Its uv is 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, so fullscreen_vs picks 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_ndc is its exact inverse, switched the same way, for the passes that reconstruct a position from the uv (deferred.slang on SPIR-V, ssao.slang on WGSL). BuildIntegrity.NoShaderRedeclaresTheFullscreenUvMapping fails 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++) and world_up_is_screen_up (OxidANT tests/headless.rs) pin it.

  • common/material_fetch.slang — the objectDescription binding and the fetch_object_description/fetch_material lookups 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.slang declares its own objectDescription binding, so it cannot import this module.

  • common/material_rules.slang — the binding-free glTF material rules on the fetched ObjMaterial: material_roughness, material_metallic_roughness (glTF SS3.9.2 G=roughness/B=metallic channel swizzle) and alpha_masked_out (alphaMode MASK). Split out of material_fetch.slang so path_tracing.slang can call them, as with base_color.slang below. Spirv only, every C++ shading path (raster, deferred, shadow map, ray tracing, path tracing).

  • common/material_textures.slang — the one declaration of the textures[]/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.slang and alpha_test.slang; path_tracing.slang gets it through alpha_test.slang and 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) and transform_uv (KHR_texture_transform), in a bindingless module so every spirv shading path can import it. It keeps rasterizer/rasterizer.slang, deferred/deferred.slang, raytracing/raytrace.rchit.slang and path_tracing/path_tracing.slang in agreement with the reference prim.base_color * baseSample in forward/forward.slang (wgsl, not an importer); before it, those four dropped baseColorFactor whenever a material also had a texture. The alpha half travels separately: fromGltfMaterial puts baseColorFactor[0..2] in diffuse and baseColorFactor[3] in dissolve, which alpha_masked_out() multiplies into the sampled alpha (the ObjMaterial table in model-loading.md). Spirv only.

  • common/emission.slang — the glTF emissive rule (material_emission: emissiveFactor * sampled emissiveTexture, 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 as emission.slang; forward/forward.slang’s fs_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 and path_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 by sky/sky.slang (the Rust/WebGPU background) and forward/forward.slang (the analytic IBL fallback, whose reflections and irradiance must agree with the background). There is deliberately no combined sky_radiance: each caller takes its light direction/intensity from a different uniform block, so each keeps a thin local wrapper over sky_gradient + sun_disk. tests/sky_constants.rs pins the three gradient constants a third time on the Rust CPU side (render::ibl::SKY_ZENITH/SKY_HORIZON/SKY_GROUND, consumed by EnvironmentImage::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

bloom/bloom.slang

wgsl

compute/clouds.slang

spirv

compute/noise.slang

spirv

deferred/deferred.slang

spirv

depth_resolve/depth_resolve.slang

wgsl

forward/forward.slang

wgsl

gpu_cull/gpu_cull.slang

wgsl

ibl/ibl.slang

wgsl

occlusion_bbox/occlusion_bbox.slang

wgsl

path_tracing/path_tracing.slang

spirv

post/post.slang

spirv

rasterizer/rasterizer.slang

spirv

rasterizer/shadows/shadow_map.slang

spirv

raytracing/raytrace.rahit.slang

spirv

raytracing/raytrace.rchit.slang

spirv

raytracing/raytrace.rgen.slang

spirv

raytracing/raytrace.rmiss.slang

spirv

raytracing/shadow.rmiss.slang

spirv

sky/sky.slang

wgsl

skybox/skybox.slang

spirv

ssao/ssao.slang

wgsl

tex_quad/tex_quad.slang

wgsl

tonemap/tonemap.slang

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

common/aces.slang

both

common/alpha_test.slang

spirv

common/base_color.slang

spirv

common/brdf.slang

both

common/cascaded_shadow.slang

spirv

common/emission.slang

spirv

common/fullscreen.slang

both

common/material_fetch.slang

spirv

common/material_rules.slang

spirv

common/material_textures.slang

spirv

common/noise.slang

both

common/normal_map.slang

spirv

common/push_constants.slang

spirv

common/raster_geometry.slang

spirv

common/scene_types.slang

spirv

common/sky_model.slang

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.

Known glTF loader divergences (not shader-shared, but the two renderers must stay honest about it)#

Feature

C++ GltfLoader

Rust gltf_loader

Note

Base-colour UV set beyond TEXCOORD_0

fromGltfMaterial supports only TEXCOORD_0 and warns when a material’s base-colour texture or KHR_texture_transform names anything else

uv_set_bit supports TEXCOORD_0/1 and warns only past that

The Rust masked-shadow pass (forward.slang’s vs_shadow_masked/fs_shadow_masked) honours the base-colour slot’s per-slot UV mask like the forward pass, so a MASK material whose base-colour texture declares TEXCOORD_1 casts a shadow silhouette matching its forward-pass alpha test. The C++ shadow pass needs no such selector: it never reads past TEXCOORD_0.

KHR_materials_unlit

fromGltfMaterial reads material.unlit into ObjMaterial::unlit, honoured in all four shading paths (rasterizer.slang, deferred.slang, raytrace.rchit.slang, path_tracing.slang)

reads material.unlit() into CpuMaterial::unlit

Parity: both loaders and every shading path on both sides treat the extension identically.

occlusionTexture + occlusionStrength

fromGltfMaterial never reads cgltf_material::occlusion_texture; ObjMaterial has no occlusion field

reads occlusion_texture()/occlusion_strength into CpuMaterial::occlusion_texture/occlusion_strength

Intended divergence: glTF scopes occlusionTexture to indirect light, and neither C++ raster path has an indirect term (rasterizer.slang’s fs_main and the deferred lighting pass are brdf_direct + shadow + emissive only), so an AO map has nothing to attenuate. A future IBL or ambient term would make the slot meaningful.

Per-slot KHR_texture_transform

fromGltfMaterial gives base-colour, normal, metallic-roughness and emissive each their own transform (uv_transform_row0/_row1, normal_uv_transform_row0/_row1, metallic_roughness_uv_transform_row0/_row1, emissive_uv_transform_row0/_row1); no occlusion slot, since occlusion textures are not read (previous row)

the same four slots plus occlusion_uv_transform

Parity wherever C++ has a texture to transform; the only gap is the occlusion gap above, not a KHR_texture_transform bug.

alphaMode BLEND

fromGltfMaterial’s alphaCutoff sentinel maps BLEND to the same -1 as OPAQUE (never discards), so a BLEND material renders opaque; rationale in fromGltfMaterial’s alphaCutoff comment

AlphaMode::Blend drives a real sorted back-to-front alpha-blend pass (the alpha_blend primitive flag and its distance sort)

Intended divergence: real BLEND compositing needs a sorted transparent pass this engine does not have yet, so it renders opaque.

Roughness default when Pr is unauthored (OBJ path)

ObjLoader treats an absent (or 0.0) Pr as unauthored and leaves ObjMaterial::roughness at its sentinel, so material_roughness() derives it from shininess/Ns

the OBJ→glTF converter (obj_to_gltf) emits roughnessFactor from Ns via the same sqrt(2/(Ns+2)) curve, clamped [0.045, 1.0], when Pr is absent

Parity: both paths derive the same roughness from the .mtl’s Ns when Pr is unauthored; material_roughness() (material_rules.slang) is the shared reference both sides implement.

map_d (OBJ per-texel opacity map)

ObjLoader::loadTexturesAndMaterials reads map_d into ObjMaterial::alphaTextureID as a glTF MASK cut-out at the conventional alphaCutoff = 0.5, honoured in rasterizer.slang, deferred.slang, shadow_map.slang and common/alpha_test.slang

obj_to_gltf reads only the scalar d/Tr directives into a per-material alpha and emits alphaMode: BLEND when it is below 1.0 (alphaMode BLEND row above); it has no code path for a textured opacity map, so a map_d material converts with neither a cut-out nor per-texel alpha

Real gap: obj_to_gltf would need to emit a baseColorTexture.a channel (or a dedicated occlusion-style slot) from map_d to match the C++ side.

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:

  • metallic is a full factor-or-texture read on three paths, but on path_tracing.slang only the metallic-roughness texture branch touches it (material_textures.slang’s comment says why that path skips resolved_metallic_roughness_lod0()).

  • shininess is only reachable through material_roughness()’s negative-roughness fallback (“(fallback)” in its row).

  • dissolve is read only inside alpha_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, in alpha_test.slang’s ray_hit_masked_out()) never touches it.

Member

rasterizer.slang

deferred.slang

raytrace.rchit.slang

path_tracing.slang

shadow_map.slang / alpha_test.slang

diffuse

material.diffuse (untextured) / base_color() (textured)

material.diffuse (untextured) / base_color() (textured)

material.diffuse (untextured) / base_color() (textured)

material.diffuse (untextured) / base_color() (textured)

not read — alpha testing only discards a fragment, never shades a colour

emission

resolved_emission()

resolved_emission()

resolved_emission_lod0()

resolved_emission_lod0() (skipped in furnace-test mode)

not read — no lighting

shininess

resolved_metallic_roughness() (fallback)

resolved_metallic_roughness() (fallback)

resolved_metallic_roughness_lod0() (fallback)

not read — no roughness/specular term at all (only the metallic half of material_metallic_roughness() is used)

not read — no lighting

dissolve

alpha_masked_out()

alpha_masked_out()

not read — the MASK cut-out for ray-traced hits runs in the any-hit shader (raytrace.rahit.slang, via alpha_test.slang)

not read — candidates are alpha-tested earlier, by alpha_test.slang’s ray_hit_masked_out()

alpha_masked_out()

textureID

material.textureID

material.textureID

material.textureID

material.textureID

material.textureID

alphaCutoff

alpha_masked_out()

alpha_masked_out()

not read — see dissolve

not read — see dissolve

material.alphaCutoff (outer guard) / alpha_masked_out()

uv_transform_row0

transform_uv()

transform_uv()

transform_uv()

transform_uv()

transform_uv()

uv_transform_row1

transform_uv()

transform_uv()

transform_uv()

transform_uv()

transform_uv()

metallic

resolved_metallic_roughness()

resolved_metallic_roughness()

resolved_metallic_roughness_lod0()

material_metallic_roughness()’s raw metallic term, gated on metallicRoughnessTextureID >= 0 only — no untextured-factor fallback

not read — no lighting

roughness

resolved_metallic_roughness()

resolved_metallic_roughness()

resolved_metallic_roughness_lod0()

not read — only the metallic half (.x) of material_metallic_roughness() is used

not read — no lighting

emissiveTextureID

resolved_emission()

resolved_emission()

resolved_emission_lod0()

resolved_emission_lod0()

not read — no lighting

normalTextureID

resolved_normal()

resolved_normal()

material.normalTextureID (outer guard) / resolved_normal_lod0()

material.normalTextureID (outer guard) / resolved_normal_lod0()

not read — no normal mapping

normalScale

resolved_normal()

resolved_normal()

resolved_normal_lod0()

resolved_normal_lod0()

not read — no normal mapping

metallicRoughnessTextureID

resolved_metallic_roughness()

resolved_metallic_roughness()

resolved_metallic_roughness_lod0()

material.metallicRoughnessTextureID (outer guard before sample_metallic_roughness_lod0())

not read — no lighting

normal_uv_transform_row0

resolved_normal()

resolved_normal()

resolved_normal_lod0()

resolved_normal_lod0()

not read — no normal mapping

normal_uv_transform_row1

resolved_normal()

resolved_normal()

resolved_normal_lod0()

resolved_normal_lod0()

not read — no normal mapping

metallic_roughness_uv_transform_row0

resolved_metallic_roughness()

resolved_metallic_roughness()

resolved_metallic_roughness_lod0()

sample_metallic_roughness_lod0(), called directly — same metallic-style exception

not read — no lighting

metallic_roughness_uv_transform_row1

resolved_metallic_roughness()

resolved_metallic_roughness()

resolved_metallic_roughness_lod0()

sample_metallic_roughness_lod0(), called directly — same metallic-style exception

not read — no lighting

emissive_uv_transform_row0

resolved_emission()

resolved_emission()

resolved_emission_lod0()

resolved_emission_lod0()

not read — no lighting

emissive_uv_transform_row1

resolved_emission()

resolved_emission()

resolved_emission_lod0()

resolved_emission_lod0()

not read — no lighting

unlit

material.unlit

material.unlit

material.unlit

material.unlit

not read — MASK alpha testing runs regardless of unlit

alphaTextureID

material.alphaTextureID

material.alphaTextureID

not read — see dissolve

not read — see dissolve

material.alphaTextureID

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/Models becomes 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.